smodusermc commited on
Commit
cfd4ffb
·
verified ·
1 Parent(s): f5a89f2

Create test_migration.py

Browse files
Files changed (1) hide show
  1. tests/test_migration.py +202 -0
tests/test_migration.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Verify the v1 -> v2 migration upgrades an existing database without data loss."""
3
+ import asyncio
4
+ import os
5
+ import shutil
6
+ import sqlite3
7
+ import sys
8
+ import tempfile
9
+
10
+ WORK = tempfile.mkdtemp(prefix="ichat_migrate_test_")
11
+ DB = os.path.join(WORK, "infinitychat.db")
12
+
13
+ # ------------------------------------------------------------------
14
+ # 1. Build a database exactly as the OLD app (v1) created it
15
+ # ------------------------------------------------------------------
16
+ con = sqlite3.connect(DB)
17
+ con.executescript("""
18
+ CREATE TABLE users (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ username TEXT UNIQUE NOT NULL,
21
+ display_name TEXT NOT NULL DEFAULT '',
22
+ password_hash TEXT NOT NULL,
23
+ salt TEXT NOT NULL,
24
+ avatar_path TEXT,
25
+ token TEXT UNIQUE,
26
+ created_at INTEGER DEFAULT (strftime('%s','now')),
27
+ last_seen INTEGER DEFAULT (strftime('%s','now')),
28
+ status TEXT DEFAULT 'offline'
29
+ );
30
+ CREATE TABLE messages (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ sender_id INTEGER NOT NULL,
33
+ encrypted_content TEXT NOT NULL,
34
+ timestamp_ms INTEGER NOT NULL,
35
+ reply_to_id INTEGER,
36
+ is_edited INTEGER DEFAULT 0,
37
+ is_deleted INTEGER DEFAULT 0,
38
+ file_path TEXT,
39
+ file_type TEXT,
40
+ file_name TEXT,
41
+ file_size INTEGER DEFAULT 0,
42
+ FOREIGN KEY(sender_id) REFERENCES users(id)
43
+ );
44
+ CREATE TABLE read_receipts (
45
+ user_id INTEGER NOT NULL,
46
+ message_id INTEGER NOT NULL,
47
+ read_at INTEGER DEFAULT (strftime('%s','now')),
48
+ PRIMARY KEY (user_id, message_id)
49
+ );
50
+ CREATE INDEX idx_messages_sender_time ON messages(sender_id, timestamp_ms);
51
+ CREATE INDEX idx_messages_time_id ON messages(timestamp_ms, id);
52
+ CREATE INDEX idx_messages_not_deleted ON messages(is_deleted, id);
53
+ CREATE INDEX idx_receipts_user_msg ON read_receipts(user_id, message_id);
54
+ """)
55
+
56
+ # legacy data: 2 users, 3 messages, receipts in *seconds*
57
+ con.executemany(
58
+ "INSERT INTO users (username, display_name, password_hash, salt, token, status) VALUES (?,?,?,?,?,?)",
59
+ [
60
+ ("alice", "Alice", "hash1", "salt1", "tok_alice", "online"),
61
+ ("bob", "Bobby", "hash2", "salt2", "tok_bob", "offline"),
62
+ ]
63
+ )
64
+ # bob sends message (encrypted_content is opaque to migration)
65
+ con.execute(
66
+ "INSERT INTO messages (sender_id, encrypted_content, timestamp_ms) VALUES (2, 'CIPHERTEXT_HELLO', 1700000000000)"
67
+ )
68
+ con.execute(
69
+ "INSERT INTO messages (sender_id, encrypted_content, timestamp_ms) VALUES (1, 'CIPHERTEXT_WORLD', 1700000001000)"
70
+ )
71
+ # alice read bob's message at second-precision epoch 1700000002
72
+ con.execute(
73
+ "INSERT INTO read_receipts (user_id, message_id, read_at) VALUES (1, 1, 1700000002)"
74
+ )
75
+ con.commit()
76
+ con.close()
77
+
78
+ before_rows = {}
79
+
80
+ def dump(label):
81
+ c = sqlite3.connect(DB)
82
+ c.row_factory = sqlite3.Row
83
+ tables = [r["name"] for r in c.execute(
84
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")]
85
+ print(f"--- {label} tables:", tables)
86
+ for t in ("users", "messages", "read_receipts", "conversations", "conversation_members"):
87
+ try:
88
+ rows = [dict(r) for r in c.execute(f"SELECT * FROM {t} ORDER BY 1")]
89
+ print(f" {t}: {len(rows)} row(s)")
90
+ for r in rows[:6]:
91
+ print(" ", r)
92
+ except sqlite3.Error as e:
93
+ print(f" {t}: ERROR {e}")
94
+ c.close()
95
+
96
+ dump("BEFORE MIGRATION")
97
+ before_messages = sqlite3.connect(DB).execute("SELECT COUNT(*) FROM messages").fetchone()[0]
98
+ before_users = sqlite3.connect(DB).execute("SELECT COUNT(*) FROM users").fetchone()[0]
99
+
100
+ # ------------------------------------------------------------------
101
+ # 2. Run the v2 app initializer against the legacy DB
102
+ # ------------------------------------------------------------------
103
+ os.environ["DATABASE_URL"] = DB
104
+ os.environ["HF_TOKEN"] = "" # offline mode
105
+ os.environ["SECRET_KEY"] = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" # 32 bytes
106
+ os.environ["FILE_ENCRYPTION_KEY"] = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
107
+ os.environ["TEMP_DIR"] = os.path.join(WORK, "tmp")
108
+
109
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
110
+ sys.path.insert(0, REPO_ROOT)
111
+
112
+ # import storage_handler module needs clean reload w/ env; import app fresh
113
+ import importlib
114
+ import storage_handler
115
+ importlib.reload(storage_handler)
116
+
117
+ import app as app_module
118
+ importlib.reload(app_module)
119
+
120
+ asyncio.run(app_module.init_database())
121
+ print("\ninit_database() completed without data loss errors")
122
+
123
+ dump("AFTER MIGRATION")
124
+
125
+ # ------------------------------------------------------------------
126
+ # 3. Assertions
127
+ # ------------------------------------------------------------------
128
+ con = sqlite3.connect(DB)
129
+ con.row_factory = sqlite3.Row
130
+
131
+ failures = []
132
+
133
+ # data preserved
134
+ n_msgs = con.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
135
+ n_users = con.execute("SELECT COUNT(*) FROM users").fetchone()[0]
136
+ if n_msgs != before_messages:
137
+ failures.append(f"messages lost: {before_messages} -> {n_msgs}")
138
+ if n_users != before_users:
139
+ failures.append(f"users lost: {before_users} -> {n_users}")
140
+
141
+ # new schema pieces exist
142
+ cols = {r["name"] for r in con.execute("PRAGMA table_info(messages)")}
143
+ for need in ("conversation_id", "client_id"):
144
+ if need not in cols:
145
+ failures.append(f"messages missing column {need}")
146
+ convs = con.execute("SELECT COUNT(*) FROM conversations").fetchone()[0]
147
+ if convs != 1:
148
+ failures.append(f"expected seeded global conversation, found {convs}")
149
+ conv = con.execute("SELECT * FROM conversations WHERE id=1").fetchone()
150
+ if conv["type"] != "global":
151
+ failures.append("conversation 1 is not global")
152
+
153
+ # legacy messages backfilled into the global conversation
154
+ bad_conv = con.execute(
155
+ "SELECT COUNT(*) FROM messages WHERE conversation_id != 1 OR conversation_id IS NULL"
156
+ ).fetchone()[0]
157
+ if bad_conv:
158
+ failures.append(f"{bad_conv} messages not in global conversation")
159
+
160
+ # receipts upgraded from seconds to milliseconds
161
+ rc = con.execute("SELECT read_at FROM read_receipts WHERE user_id=1 AND message_id=1").fetchone()
162
+ if not rc or rc["read_at"] != 1700000002000:
163
+ failures.append(f"read_at not converted to ms: {rc and rc['read_at']}")
164
+
165
+ # encrypted content untouched
166
+ msg = con.execute("SELECT encrypted_content FROM messages WHERE id=1").fetchone()
167
+ if msg["encrypted_content"] != "CIPHERTEXT_HELLO":
168
+ failures.append("message content was modified!")
169
+
170
+ # indexes exist
171
+ idx = {r["name"] for r in con.execute("PRAGMA index_list(messages)")}
172
+ for need in ("idx_messages_client_dedupe", "idx_messages_conversation"):
173
+ if need not in idx:
174
+ failures.append(f"missing index {need}")
175
+
176
+ con.close()
177
+
178
+ # ------------------------------------------------------------------
179
+ # 4. Idempotency: run migration a second time (like a second deploy)
180
+ # ------------------------------------------------------------------
181
+ asyncio.run(app_module.init_database())
182
+ con = sqlite3.connect(DB)
183
+ n2 = con.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
184
+ c2 = con.execute("SELECT COUNT(*) FROM conversations").fetchone()[0]
185
+ u2 = con.execute("SELECT COUNT(*) FROM users").fetchone()[0]
186
+ rc2 = con.execute("SELECT read_at FROM read_receipts WHERE user_id=1 AND message_id=1").fetchone()[0]
187
+ con.close()
188
+ if n2 != n_msgs or c2 != convs or u2 != n_users:
189
+ failures.append(f"second run duplicated/lost data: msgs {n_msgs}->{n2}, convs {convs}->{c2}, users {n_users}->{u2}")
190
+ if rc2 != 1700000002000:
191
+ failures.append(f"read_at re-multiplied on second run: {rc2}")
192
+
193
+ print("\n==== RESULT ====")
194
+ if failures:
195
+ print("FAILURES:")
196
+ for f in failures:
197
+ print(" ✗", f)
198
+ sys.exit(1)
199
+ print("✓ Migration upgraded v1 DB in place with zero data loss")
200
+ print("✓ Idempotent (second run changes nothing)")
201
+ shutil.rmtree(WORK, ignore_errors=True)
202
+ sys.exit(0)