syncular-testkit 0.1.0

Rust-first test fixtures, in-memory app server, and assertions for Syncular apps.
Documentation
# syncular-testkit

Rust-first testing utilities for Syncular apps and SDK bindings.

The crate mirrors the value of `@syncular/testkit`: tests should use real SQLite
stores, generated app schema metadata, scripted transports, fault injection, and
assertions instead of mocking Syncular internals.

Initial scope:

- `TempDbPath` for disposable SQLite databases, including `-wal`, `-shm`, and
  `-journal` cleanup.
- `TestTransport` for in-process sync, websocket, auth-header, chunk, and blob
  assertions, including static and request-dependent scripted responses.
- `TestSyncServer` for disposable HTTP sync endpoints in native/client tests.
- `AppTestServer` for stateful generated-schema app tests: it stores rows,
  applies pushed commits, returns later pull snapshots/commits, merges
  server-merge CRDT/Yjs payloads, filters self commits, can reverse/duplicate
  delivery, preserves encrypted field payloads, can revoke/restore
  subscriptions, can change required auth during a test, and emits realtime
  sync wakeups. It also implements `BlobTransport` for queued upload/download
  tests.
- `AppTestHttpServer` for stateful HTTP/WebSocket app tests over the production
  native transport shape, including captured HTTP/WebSocket requests for auth
  and schema-version assertions. The same fixture can enforce a required
  authorization token for HTTP sync and WebSocket connection tests.
- `TestBlobServer` for local HTTP blob upload/download integration tests.
- `FaultTransport` for scripted transport failures and latency.
- Protocol builders for snapshot pages/chunks, pull commits, conflict,
  revoked, schema-required/latest, and not-ok server responses.
- Shared conformance fixture helpers for
  `examples/todo-app/conformance/sync-scenarios.json`, so Rust runtime, SDK,
  and app tests read the same scenario values. Use
  `sync_conformance_fixture()` for the typed Rust contract, and keep the path
  helpers for small one-off assertions.
- Offline auth lease helpers for issuing and verifying deterministic ES256
  test tokens without making app tests build a private signer.
- `AppFixture` helpers that accept a generated `AppSchema` from consuming apps,
  including file-backed temp DBs and Rust-only in-memory DBs.
- `TodoFixture` helpers backed by the generated todo schema and Diesel SQLite.
- Native fixture helpers for opening real `NativeSyncularClient` instances with
  embedded schema JSON or direct app schemas, plus event waiters.
- CRDT field helpers for applying text updates and asserting materialized
  Rust/native field values.
- Assertions for outbox, conflicts, stateful app server rows/commits/auth,
  captured HTTP request headers, blob queue, blob cache state, and stable
  native error/diagnostic codes.

## App Usage

Add the local testkit crate as a dev dependency while testing against this
workspace:

```toml
[dev-dependencies]
syncular-testkit = { path = "/Users/bkniffler/conductor/workspaces/syncular/indianapolis/rust/crates/testkit" }
syncular-runtime = { path = "/Users/bkniffler/conductor/workspaces/syncular/indianapolis/rust/crates/runtime" }
```

Use your generated app schema, not the repo todo fixture:

```rust
use syncular_testkit::{
    assert_outbox_statuses, assert_table_row_count, open_app_client,
    snapshot_combined_response,
};

#[test]
fn syncs_remote_rows_without_mocking_syncular() {
    let mut fixture = open_app_client(my_app::generated::app_schema()).unwrap();

    fixture.transport.push_http_response(snapshot_combined_response(
        "sub-notes",
        "notes",
        vec![serde_json::json!({
            "id": "note-1",
            "title": "Remote note",
            "owner_id": "user-rust",
            "server_version": 1
        })],
        syncular_testkit::actor_project_scopes("user-rust", None),
        1,
    ));

    fixture.client.sync_http().unwrap();
    assert_table_row_count(&mut fixture.client, "notes", 1);
    assert_outbox_statuses(&mut fixture.client, &[]);
}
```

For multi-client app behavior, use `AppTestServer`. It is generic over the
generated `AppSchema`, so app-specific tests only need to provide seed rows and
assert app-specific fields:

```rust
use syncular_testkit::{
    AppFixtureOptions, AppTestServer, assert_app_server_commit_count,
    assert_app_server_has_row, assert_table_has_row, open_app_client_with_server,
};

#[test]
fn two_clients_sync_through_stateful_test_server() {
    let app_schema = my_app::generated::app_schema();
    let server = AppTestServer::new(app_schema);

    let mut writer = open_app_client_with_server(
        app_schema,
        server.clone(),
        AppFixtureOptions {
            client_id: "writer".to_string(),
            ..AppFixtureOptions::default()
        },
    ).unwrap();
    let mut reader = open_app_client_with_server(
        app_schema,
        server.clone(),
        AppFixtureOptions {
            client_id: "reader".to_string(),
            ..AppFixtureOptions::default()
        },
    ).unwrap();

    writer.client.apply_mutation_json(
        &serde_json::json!({
            "table": "notes",
            "row_id": "note-1",
            "op": "upsert",
            "payload": { "title": "From writer", "owner_id": "user-rust" },
            "base_version": 0
        }).to_string(),
        None,
    ).unwrap();
    writer.client.sync_http().unwrap();
    assert_eq!(
        assert_app_server_has_row(&server, "notes", "note-1")["title"],
        "From writer"
    );
    assert_app_server_commit_count(&server, 1, std::time::Duration::from_secs(1));

    reader.client.sync_http().unwrap();
    assert_table_has_row(&mut reader.client, "notes", "id", "note-1");
}
```

For binding or app-shell tests that need the production HTTP/WebSocket transport
shape, wrap the same stateful server in `AppTestHttpServer`:

```rust
use syncular_runtime::transport::{HttpSyncTransport, SyncTransportConfig};
use syncular_testkit::{
    AppFixtureOptions, AppTestHttpServer, assert_table_has_row,
    open_app_client_with_transport,
};

#[test]
fn syncs_against_stateful_http_server() {
    let app_schema = my_app::generated::app_schema();
    let server = AppTestHttpServer::start(app_schema).unwrap();
    let options = AppFixtureOptions {
        base_url: server.url(),
        client_id: "reader".to_string(),
        ..AppFixtureOptions::default()
    };
    let transport = HttpSyncTransport::new(SyncTransportConfig::new(
        options.base_url.clone(),
        options.client_id.clone(),
        options.actor_id.clone(),
    ));
    let mut fixture =
        open_app_client_with_transport(app_schema, transport, options).unwrap();

    server.app_server().commit_row("notes", serde_json::json!({
        "id": "note-1",
        "title": "Server note",
        "owner_id": "user-rust"
    })).unwrap();

    fixture.client.sync_http().unwrap();
    assert_table_has_row(&mut fixture.client, "notes", "id", "note-1");
    let requests = server.wait_for_requests(1, std::time::Duration::from_secs(1));
    syncular_testkit::assert_http_request_header(
        &requests[0],
        "x-syncular-schema-version",
        &app_schema.current_schema_version().to_string(),
    );
}
```

To test app auth behavior without a private server mock, configure the stateful
server with a required authorization header:

```rust
use syncular_testkit::{
    AppTestHttpServer, AppTestServer, AppTestServerOptions, assert_app_server_auth_header,
};

let app_schema = my_app::generated::app_schema();
let server = AppTestHttpServer::start_with_server(AppTestServer::with_options(
    app_schema,
    AppTestServerOptions::default().require_authorization("Bearer test-token"),
)).unwrap();

// Requests without `authorization: Bearer test-token` receive HTTP 401, and
// WebSocket connections with the wrong token are rejected during the handshake.
// After an authorized request:
assert_app_server_auth_header(server.app_server(), "authorization", "Bearer test-token");
```

The required token can also change inside one test, which is useful for auth
refresh flows:

```rust
server.app_server().require_authorization("Bearer refreshed-token");
// trigger a rejected sync with stale auth, refresh app auth state, then retry
server.app_server().clear_required_authorization();
```

For rolling-deploy tests, the same stateful server can advertise future schema
versions and let the real client fail closed:

```rust
use syncular_testkit::{AppTestHttpServer, AppTestServer, AppTestServerOptions};

let app_schema = my_app::generated::app_schema();
let required_schema_version = app_schema.current_schema_version() + 1;
let server = AppTestHttpServer::start_with_server(AppTestServer::with_options(
    app_schema,
    AppTestServerOptions::default().require_schema_version(required_schema_version),
)).unwrap();

// A client with the current generated schema receives `sync.schema_mismatch`
// and must not apply rows from this response.
```

For scoped access revocation, the stateful server can return a real revoked
subscription response, which lets the client clear the previous scoped rows and
reset its cursor without a scripted transport:

```rust
server.app_server().revoke_subscription("sub-notes");
// next sync clears rows for the previous subscription state
server.app_server().restore_subscription("sub-notes");
// following sync bootstraps visible rows again
```

For native-style tests, open a real native client with the same generated schema
or with generated schema JSON:

```rust
use std::time::Duration;
use syncular_runtime::native::NativeEventKind;
use syncular_testkit::{
    open_native_client_with_schema, wait_native_event, assert_native_rows_changed,
};

#[test]
fn native_local_write_emits_rows_changed() {
    let mut fixture = open_native_client_with_schema(my_app::generated::app_schema()).unwrap();

    fixture.client.apply_mutation_json(
        &serde_json::json!({
            "table": "notes",
            "row_id": "note-1",
            "op": "upsert",
            "payload": { "title": "Draft", "owner_id": "user-rust" },
            "base_version": 0
        }).to_string(),
        None,
    ).unwrap();

    let event = wait_native_event(
        &fixture.events,
        NativeEventKind::RowsChanged,
        Duration::from_secs(1),
    );
    assert_native_rows_changed(&event, &["notes"]);
    fixture.close().unwrap();
}
```

For host/HTTP integration tests, use `TestSyncServer` instead of standing up a
production server:

```rust
use syncular_testkit::{TestSyncServer, empty_success_response};

let server = TestSyncServer::spawn([empty_success_response()]).unwrap();
let base_url = server.url();
```

For request-dependent protocol cases, queue a response function. This is useful
for push conflicts or duplicate acknowledgements where the response needs the
client commit id from the actual request:

```rust
use syncular_testkit::{push_conflict_response, todo_task_row};

fixture.transport.push_http_response_fn(|request| {
    Ok(push_conflict_response(
        request,
        "version conflict",
        "sync.version_conflict",
        todo_task_row("note-1", "Server row", 9),
        9,
    ))
});
```

## Conformance Gates

When changing the testkit or shared fixtures, run the fast Rust-first
conformance lane:

```bash
bun run rust:conformance:fast
```

Use the heavier lanes when changing browser/Hono behavior or native bindings:

```bash
bun run rust:conformance
bun run rust:conformance:native
```

Reusable runtime test patterns that should move here over time:

- `protocol_contract.rs`: most generic protocol scripting now uses
  `TestTransport`, protocol builders, and `FaultTransport`. The remaining local
  mock is intentionally scoped to encrypted row/chunk/blob fixtures and
  lock-reentrancy tests.
- `native_facade.rs` / `native_ffi.rs`: temp database paths, todo app schema JSON
  setup, native event waiters, and generated row assertions.
- `blob_transport.rs`: blob queue/cache assertions and local HTTP blob transport.
- `crdt_field.rs`: remaining encrypted CRDT system-table fixtures and
  ciphertext roundtrip assertions. Server-merge convergence fixtures now use
  `AppTestServer`.
- `store_backends.rs`: generic backend parity sync fixtures now use
  `TestTransport` and protocol builders. The remaining local transports are
  encrypted CRDT system-table fixtures.