Skip to main content

max / makenotwork

8.2 KB · 269 lines History Blame Raw
1 //! Selective sync (table + timestamp filtering) integration tests.
2
3 use crate::harness::TestHarness;
4 use makenotwork::db::{SyncAppId, SyncDeviceId, UserId};
5 use serde::Deserialize;
6 use serde_json::json;
7 use sqlx::PgPool;
8
9 // ── Response types ──
10
11 #[derive(Deserialize)]
12 struct AuthResponse {
13 token: String,
14 #[allow(dead_code)]
15 user_id: UserId,
16 #[allow(dead_code)]
17 app_id: SyncAppId,
18 }
19
20 #[derive(Deserialize)]
21 struct DeviceResponse {
22 id: SyncDeviceId,
23 }
24
25 #[derive(Deserialize)]
26 struct PushResponse {
27 #[allow(dead_code)]
28 cursor: i64,
29 }
30
31 #[derive(Deserialize)]
32 struct PullResponse {
33 changes: Vec<PullChange>,
34 cursor: i64,
35 has_more: bool,
36 }
37
38 #[derive(Deserialize)]
39 struct PullChange {
40 table: String,
41 #[allow(dead_code)]
42 op: String,
43 #[allow(dead_code)]
44 row_id: String,
45 }
46
47 // ── Helpers ──
48
49 async fn create_sync_app_for_user(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) {
50 let api_key = "test-selective-api-key";
51 let key_hash = crate::harness::hash_api_key(api_key);
52 let key_prefix = &api_key[..8];
53 let app_id: SyncAppId = sqlx::query_scalar(
54 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, 'AudioFiles', $2, $3) RETURNING id",
55 )
56 .bind(user_id)
57 .bind(&key_hash)
58 .bind(key_prefix)
59 .fetch_one(pool)
60 .await
61 .expect("Failed to create sync app");
62 (app_id, api_key.to_string())
63 }
64
65 async fn setup_authenticated(h: &mut TestHarness) -> (String, SyncAppId) {
66 let user_id = h.signup("sel_user", "sel@example.com", "Password1!").await;
67 let (app_id, api_key) = create_sync_app_for_user(&h.db, user_id).await;
68
69 let resp = h
70 .client
71 .post_json(
72 "/api/sync/auth",
73 &json!({
74 "email": "sel@example.com",
75 "password": "Password1!",
76 "api_key": api_key,
77 "key": "test-sdk-key",
78 })
79 .to_string(),
80 )
81 .await;
82 assert_eq!(resp.status, 200, "Auth failed: {}", resp.text);
83 let auth: AuthResponse = resp.json();
84 h.client.set_bearer_token(&auth.token);
85 (auth.token, app_id)
86 }
87
88 async fn register_device(h: &mut TestHarness, name: &str) -> SyncDeviceId {
89 let resp = h
90 .client
91 .post_json(
92 "/api/sync/devices",
93 &json!({ "device_name": name, "platform": "macos" }).to_string(),
94 )
95 .await;
96 assert_eq!(resp.status, 200, "Register device failed: {}", resp.text);
97 let dev: DeviceResponse = resp.json();
98 dev.id
99 }
100
101 /// Push a mixed set of changes (tasks + events + contacts) and return the push cursor.
102 async fn push_mixed_changes(h: &mut TestHarness, device_id: SyncDeviceId) -> i64 {
103 let resp = h
104 .client
105 .post_json(
106 "/api/sync/push",
107 &json!({
108 "device_id": device_id,
109 "batch_id": uuid::Uuid::new_v4().to_string(),
110 "changes": [
111 { "table": "tasks", "op": "INSERT", "row_id": "t1", "timestamp": "2025-01-01T00:00:00Z", "data": {"title": "Task 1"} },
112 { "table": "events", "op": "INSERT", "row_id": "e1", "timestamp": "2025-01-01T01:00:00Z", "data": {"title": "Event 1"} },
113 { "table": "tasks", "op": "UPDATE", "row_id": "t1", "timestamp": "2025-01-01T02:00:00Z", "data": {"title": "Task 1 updated"} },
114 { "table": "contacts", "op": "INSERT", "row_id": "c1", "timestamp": "2025-01-01T03:00:00Z", "data": {"name": "Alice"} },
115 { "table": "events", "op": "DELETE", "row_id": "e1", "timestamp": "2025-01-01T04:00:00Z" },
116 ]
117 })
118 .to_string(),
119 )
120 .await;
121 assert_eq!(resp.status, 200, "Push failed: {}", resp.text);
122 let push: PushResponse = resp.json();
123 push.cursor
124 }
125
126 // ── Tests ──
127
128 #[tokio::test]
129 async fn pull_with_table_filter_returns_only_matching() {
130 let mut h = TestHarness::new().await;
131 setup_authenticated(&mut h).await;
132 let device_id = register_device(&mut h, "FilterDevice").await;
133 push_mixed_changes(&mut h, device_id).await;
134
135 // Pull only "tasks" entries
136 let resp = h
137 .client
138 .post_json(
139 "/api/sync/pull",
140 &json!({
141 "device_id": device_id,
142 "cursor": 0,
143 "tables": ["tasks"]
144 })
145 .to_string(),
146 )
147 .await;
148 assert_eq!(resp.status, 200, "Pull failed: {}", resp.text);
149 let pull: PullResponse = resp.json();
150
151 assert_eq!(pull.changes.len(), 2, "Expected 2 tasks entries");
152 for change in &pull.changes {
153 assert_eq!(change.table, "tasks");
154 }
155 }
156
157 #[tokio::test]
158 async fn pull_with_multiple_tables_returns_union() {
159 let mut h = TestHarness::new().await;
160 setup_authenticated(&mut h).await;
161 let device_id = register_device(&mut h, "MultiDevice").await;
162 push_mixed_changes(&mut h, device_id).await;
163
164 // Pull "tasks" and "contacts" entries
165 let resp = h
166 .client
167 .post_json(
168 "/api/sync/pull",
169 &json!({
170 "device_id": device_id,
171 "cursor": 0,
172 "tables": ["tasks", "contacts"]
173 })
174 .to_string(),
175 )
176 .await;
177 assert_eq!(resp.status, 200, "Pull failed: {}", resp.text);
178 let pull: PullResponse = resp.json();
179
180 assert_eq!(pull.changes.len(), 3, "Expected 2 tasks + 1 contact = 3 entries");
181 let tables: Vec<&str> = pull.changes.iter().map(|c| c.table.as_str()).collect();
182 assert!(tables.contains(&"tasks"));
183 assert!(tables.contains(&"contacts"));
184 assert!(!tables.contains(&"events"));
185 }
186
187 #[tokio::test]
188 async fn pull_with_since_filter_returns_entries_after_timestamp() {
189 let mut h = TestHarness::new().await;
190 setup_authenticated(&mut h).await;
191 let device_id = register_device(&mut h, "SinceDevice").await;
192 push_mixed_changes(&mut h, device_id).await;
193
194 // Pull entries with client_timestamp >= 2025-01-01T02:00:00Z
195 // Should return: tasks UPDATE (02:00), contacts INSERT (03:00), events DELETE (04:00)
196 let resp = h
197 .client
198 .post_json(
199 "/api/sync/pull",
200 &json!({
201 "device_id": device_id,
202 "cursor": 0,
203 "since": "2025-01-01T02:00:00Z"
204 })
205 .to_string(),
206 )
207 .await;
208 assert_eq!(resp.status, 200, "Pull failed: {}", resp.text);
209 let pull: PullResponse = resp.json();
210
211 assert_eq!(pull.changes.len(), 3, "Expected 3 entries at or after 02:00");
212 }
213
214 #[tokio::test]
215 async fn pull_with_tables_and_since_composes_correctly() {
216 let mut h = TestHarness::new().await;
217 setup_authenticated(&mut h).await;
218 let device_id = register_device(&mut h, "ComboDevice").await;
219 push_mixed_changes(&mut h, device_id).await;
220
221 // Pull only "tasks" entries with client_timestamp >= 2025-01-01T02:00:00Z
222 // Should return: tasks UPDATE (02:00) only
223 let resp = h
224 .client
225 .post_json(
226 "/api/sync/pull",
227 &json!({
228 "device_id": device_id,
229 "cursor": 0,
230 "tables": ["tasks"],
231 "since": "2025-01-01T02:00:00Z"
232 })
233 .to_string(),
234 )
235 .await;
236 assert_eq!(resp.status, 200, "Pull failed: {}", resp.text);
237 let pull: PullResponse = resp.json();
238
239 assert_eq!(pull.changes.len(), 1, "Expected 1 entry (tasks after 02:00)");
240 assert_eq!(pull.changes[0].table, "tasks");
241 }
242
243 #[tokio::test]
244 async fn pull_without_filters_returns_everything() {
245 let mut h = TestHarness::new().await;
246 setup_authenticated(&mut h).await;
247 let device_id = register_device(&mut h, "NoFilterDevice").await;
248 push_mixed_changes(&mut h, device_id).await;
249
250 // Pull with no filters — backward compatible
251 let resp = h
252 .client
253 .post_json(
254 "/api/sync/pull",
255 &json!({
256 "device_id": device_id,
257 "cursor": 0
258 })
259 .to_string(),
260 )
261 .await;
262 assert_eq!(resp.status, 200, "Pull failed: {}", resp.text);
263 let pull: PullResponse = resp.json();
264
265 assert_eq!(pull.changes.len(), 5, "Expected all 5 entries");
266 assert!(!pull.has_more);
267 assert!(pull.cursor > 0);
268 }
269