Skip to main content

max / makenotwork

8.3 KB · 281 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!(
181 pull.changes.len(),
182 3,
183 "Expected 2 tasks + 1 contact = 3 entries"
184 );
185 let tables: Vec<&str> = pull.changes.iter().map(|c| c.table.as_str()).collect();
186 assert!(tables.contains(&"tasks"));
187 assert!(tables.contains(&"contacts"));
188 assert!(!tables.contains(&"events"));
189 }
190
191 #[tokio::test]
192 async fn pull_with_since_filter_returns_entries_after_timestamp() {
193 let mut h = TestHarness::new().await;
194 setup_authenticated(&mut h).await;
195 let device_id = register_device(&mut h, "SinceDevice").await;
196 push_mixed_changes(&mut h, device_id).await;
197
198 // Pull entries with client_timestamp >= 2025-01-01T02:00:00Z
199 // Should return: tasks UPDATE (02:00), contacts INSERT (03:00), events DELETE (04:00)
200 let resp = h
201 .client
202 .post_json(
203 "/api/sync/pull",
204 &json!({
205 "device_id": device_id,
206 "cursor": 0,
207 "since": "2025-01-01T02:00:00Z"
208 })
209 .to_string(),
210 )
211 .await;
212 assert_eq!(resp.status, 200, "Pull failed: {}", resp.text);
213 let pull: PullResponse = resp.json();
214
215 assert_eq!(
216 pull.changes.len(),
217 3,
218 "Expected 3 entries at or after 02:00"
219 );
220 }
221
222 #[tokio::test]
223 async fn pull_with_tables_and_since_composes_correctly() {
224 let mut h = TestHarness::new().await;
225 setup_authenticated(&mut h).await;
226 let device_id = register_device(&mut h, "ComboDevice").await;
227 push_mixed_changes(&mut h, device_id).await;
228
229 // Pull only "tasks" entries with client_timestamp >= 2025-01-01T02:00:00Z
230 // Should return: tasks UPDATE (02:00) only
231 let resp = h
232 .client
233 .post_json(
234 "/api/sync/pull",
235 &json!({
236 "device_id": device_id,
237 "cursor": 0,
238 "tables": ["tasks"],
239 "since": "2025-01-01T02:00:00Z"
240 })
241 .to_string(),
242 )
243 .await;
244 assert_eq!(resp.status, 200, "Pull failed: {}", resp.text);
245 let pull: PullResponse = resp.json();
246
247 assert_eq!(
248 pull.changes.len(),
249 1,
250 "Expected 1 entry (tasks after 02:00)"
251 );
252 assert_eq!(pull.changes[0].table, "tasks");
253 }
254
255 #[tokio::test]
256 async fn pull_without_filters_returns_everything() {
257 let mut h = TestHarness::new().await;
258 setup_authenticated(&mut h).await;
259 let device_id = register_device(&mut h, "NoFilterDevice").await;
260 push_mixed_changes(&mut h, device_id).await;
261
262 // Pull with no filters, backward compatible
263 let resp = h
264 .client
265 .post_json(
266 "/api/sync/pull",
267 &json!({
268 "device_id": device_id,
269 "cursor": 0
270 })
271 .to_string(),
272 )
273 .await;
274 assert_eq!(resp.status, 200, "Pull failed: {}", resp.text);
275 let pull: PullResponse = resp.json();
276
277 assert_eq!(pull.changes.len(), 5, "Expected all 5 entries");
278 assert!(!pull.has_more);
279 assert!(pull.cursor > 0);
280 }
281