//! Selective sync (table + timestamp filtering) integration tests. use crate::harness::TestHarness; use makenotwork::db::{SyncAppId, SyncDeviceId, UserId}; use serde::Deserialize; use serde_json::json; use sqlx::PgPool; // ── Response types ── #[derive(Deserialize)] struct AuthResponse { token: String, #[allow(dead_code)] user_id: UserId, #[allow(dead_code)] app_id: SyncAppId, } #[derive(Deserialize)] struct DeviceResponse { id: SyncDeviceId, } #[derive(Deserialize)] struct PushResponse { #[allow(dead_code)] cursor: i64, } #[derive(Deserialize)] struct PullResponse { changes: Vec, cursor: i64, has_more: bool, } #[derive(Deserialize)] struct PullChange { table: String, #[allow(dead_code)] op: String, #[allow(dead_code)] row_id: String, } // ── Helpers ── async fn create_sync_app_for_user(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) { let api_key = "test-selective-api-key"; let key_hash = crate::harness::hash_api_key(api_key); let key_prefix = &api_key[..8]; let app_id: SyncAppId = sqlx::query_scalar( "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, 'AudioFiles', $2, $3) RETURNING id", ) .bind(user_id) .bind(&key_hash) .bind(key_prefix) .fetch_one(pool) .await .expect("Failed to create sync app"); (app_id, api_key.to_string()) } async fn setup_authenticated(h: &mut TestHarness) -> (String, SyncAppId) { let user_id = h.signup("sel_user", "sel@example.com", "Password1!").await; let (app_id, api_key) = create_sync_app_for_user(&h.db, user_id).await; let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "sel@example.com", "password": "Password1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; assert_eq!(resp.status, 200, "Auth failed: {}", resp.text); let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); (auth.token, app_id) } async fn register_device(h: &mut TestHarness, name: &str) -> SyncDeviceId { let resp = h .client .post_json( "/api/sync/devices", &json!({ "device_name": name, "platform": "macos" }).to_string(), ) .await; assert_eq!(resp.status, 200, "Register device failed: {}", resp.text); let dev: DeviceResponse = resp.json(); dev.id } /// Push a mixed set of changes (tasks + events + contacts) and return the push cursor. async fn push_mixed_changes(h: &mut TestHarness, device_id: SyncDeviceId) -> i64 { let resp = h .client .post_json( "/api/sync/push", &json!({ "device_id": device_id, "batch_id": uuid::Uuid::new_v4().to_string(), "changes": [ { "table": "tasks", "op": "INSERT", "row_id": "t1", "timestamp": "2025-01-01T00:00:00Z", "data": {"title": "Task 1"} }, { "table": "events", "op": "INSERT", "row_id": "e1", "timestamp": "2025-01-01T01:00:00Z", "data": {"title": "Event 1"} }, { "table": "tasks", "op": "UPDATE", "row_id": "t1", "timestamp": "2025-01-01T02:00:00Z", "data": {"title": "Task 1 updated"} }, { "table": "contacts", "op": "INSERT", "row_id": "c1", "timestamp": "2025-01-01T03:00:00Z", "data": {"name": "Alice"} }, { "table": "events", "op": "DELETE", "row_id": "e1", "timestamp": "2025-01-01T04:00:00Z" }, ] }) .to_string(), ) .await; assert_eq!(resp.status, 200, "Push failed: {}", resp.text); let push: PushResponse = resp.json(); push.cursor } // ── Tests ── #[tokio::test] async fn pull_with_table_filter_returns_only_matching() { let mut h = TestHarness::new().await; setup_authenticated(&mut h).await; let device_id = register_device(&mut h, "FilterDevice").await; push_mixed_changes(&mut h, device_id).await; // Pull only "tasks" entries let resp = h .client .post_json( "/api/sync/pull", &json!({ "device_id": device_id, "cursor": 0, "tables": ["tasks"] }) .to_string(), ) .await; assert_eq!(resp.status, 200, "Pull failed: {}", resp.text); let pull: PullResponse = resp.json(); assert_eq!(pull.changes.len(), 2, "Expected 2 tasks entries"); for change in &pull.changes { assert_eq!(change.table, "tasks"); } } #[tokio::test] async fn pull_with_multiple_tables_returns_union() { let mut h = TestHarness::new().await; setup_authenticated(&mut h).await; let device_id = register_device(&mut h, "MultiDevice").await; push_mixed_changes(&mut h, device_id).await; // Pull "tasks" and "contacts" entries let resp = h .client .post_json( "/api/sync/pull", &json!({ "device_id": device_id, "cursor": 0, "tables": ["tasks", "contacts"] }) .to_string(), ) .await; assert_eq!(resp.status, 200, "Pull failed: {}", resp.text); let pull: PullResponse = resp.json(); assert_eq!(pull.changes.len(), 3, "Expected 2 tasks + 1 contact = 3 entries"); let tables: Vec<&str> = pull.changes.iter().map(|c| c.table.as_str()).collect(); assert!(tables.contains(&"tasks")); assert!(tables.contains(&"contacts")); assert!(!tables.contains(&"events")); } #[tokio::test] async fn pull_with_since_filter_returns_entries_after_timestamp() { let mut h = TestHarness::new().await; setup_authenticated(&mut h).await; let device_id = register_device(&mut h, "SinceDevice").await; push_mixed_changes(&mut h, device_id).await; // Pull entries with client_timestamp >= 2025-01-01T02:00:00Z // Should return: tasks UPDATE (02:00), contacts INSERT (03:00), events DELETE (04:00) let resp = h .client .post_json( "/api/sync/pull", &json!({ "device_id": device_id, "cursor": 0, "since": "2025-01-01T02:00:00Z" }) .to_string(), ) .await; assert_eq!(resp.status, 200, "Pull failed: {}", resp.text); let pull: PullResponse = resp.json(); assert_eq!(pull.changes.len(), 3, "Expected 3 entries at or after 02:00"); } #[tokio::test] async fn pull_with_tables_and_since_composes_correctly() { let mut h = TestHarness::new().await; setup_authenticated(&mut h).await; let device_id = register_device(&mut h, "ComboDevice").await; push_mixed_changes(&mut h, device_id).await; // Pull only "tasks" entries with client_timestamp >= 2025-01-01T02:00:00Z // Should return: tasks UPDATE (02:00) only let resp = h .client .post_json( "/api/sync/pull", &json!({ "device_id": device_id, "cursor": 0, "tables": ["tasks"], "since": "2025-01-01T02:00:00Z" }) .to_string(), ) .await; assert_eq!(resp.status, 200, "Pull failed: {}", resp.text); let pull: PullResponse = resp.json(); assert_eq!(pull.changes.len(), 1, "Expected 1 entry (tasks after 02:00)"); assert_eq!(pull.changes[0].table, "tasks"); } #[tokio::test] async fn pull_without_filters_returns_everything() { let mut h = TestHarness::new().await; setup_authenticated(&mut h).await; let device_id = register_device(&mut h, "NoFilterDevice").await; push_mixed_changes(&mut h, device_id).await; // Pull with no filters — backward compatible let resp = h .client .post_json( "/api/sync/pull", &json!({ "device_id": device_id, "cursor": 0 }) .to_string(), ) .await; assert_eq!(resp.status, 200, "Pull failed: {}", resp.text); let pull: PullResponse = resp.json(); assert_eq!(pull.changes.len(), 5, "Expected all 5 entries"); assert!(!pull.has_more); assert!(pull.cursor > 0); }