Mercurial
diff mrjunejune/test/conversation_store_test.c @ 264:04fee26ecce0
add authenticated JRPG conversation platform
Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 07:34:12 -0700 |
| parents | 1f9877b637e9 |
| children |
line wrap: on
line diff
--- a/mrjunejune/test/conversation_store_test.c Thu Aug 06 11:31:30 2026 -0700 +++ b/mrjunejune/test/conversation_store_test.c Fri Aug 07 07:34:12 2026 -0700 @@ -1,17 +1,26 @@ #include "mrjunejune/conversation_store.h" +#include "deita/deita.h" #include <assert.h> #include <stdio.h> #include <string.h> #include <unistd.h> -int main(void) +/* Helper to create and fill a temp DB file path */ +static void make_temp_db(char *out, size_t cap) { - char database_path[] = "/tmp/mrjunejune-conversations-XXXXXX"; - int fd = mkstemp(database_path); + snprintf(out, cap, "/tmp/mrjunejune-store-test-XXXXXX"); + int fd = mkstemp(out); assert(fd >= 0); close(fd); +} +/* ------------------------------------------------------------------ */ +/* Original (legacy-API) tests */ +/* ------------------------------------------------------------------ */ + +static void test_legacy_api(const char *database_path) +{ Conversation_Store *p_store = Conversation_Store_Create(database_path); assert(p_store); @@ -32,34 +41,21 @@ assert(Conversation_Store_Update_Title( p_store, conversation_id, "Renamed quest") == CONVERSATION_STORE_OK); assert(Conversation_Store_Begin_Turn( - p_store, - conversation_id, - "request-1", - "Hello Epi") == CONVERSATION_STORE_OK); + p_store, conversation_id, "request-1", "Hello Epi") + == CONVERSATION_STORE_OK); assert(Conversation_Store_Begin_Turn( - p_store, - conversation_id, - "request-2", - "Overlapping") == CONVERSATION_STORE_CONFLICT); + p_store, conversation_id, "request-2", "Overlapping") + == CONVERSATION_STORE_CONFLICT); assert(Conversation_Store_Complete_Turn( - p_store, - conversation_id, - "request-1", - "Hello traveler", - 12, - 4) == CONVERSATION_STORE_OK); + p_store, conversation_id, "request-1", "Hello traveler", 12, 4) + == CONVERSATION_STORE_OK); assert(Conversation_Store_Begin_Turn( - p_store, - conversation_id, - "request-2", - "Try again") == CONVERSATION_STORE_OK); + p_store, conversation_id, "request-2", "Try again") + == CONVERSATION_STORE_OK); assert(Conversation_Store_Fail_Turn( - p_store, - conversation_id, - "request-2", - "cancelled", - TRUE) == CONVERSATION_STORE_OK); + p_store, conversation_id, "request-2", "cancelled", TRUE) + == CONVERSATION_STORE_OK); p_arena = Dowa_Arena_Create(32 * 1024); assert(Conversation_Store_Get( @@ -76,48 +72,641 @@ Dowa_Arena_Free(p_arena); assert(Conversation_Store_Begin_Turn( - p_store, - conversation_id, - "request-3", - "Interrupted") == CONVERSATION_STORE_OK); + p_store, conversation_id, "request-3", "Interrupted") + == CONVERSATION_STORE_OK); Conversation_Store_Destroy(p_store); p_store = Conversation_Store_Create(database_path); assert(p_store); assert(Conversation_Store_Begin_Turn( - p_store, - conversation_id, - "request-4", - "Recovered") == CONVERSATION_STORE_OK); + p_store, conversation_id, "request-4", "Recovered") + == CONVERSATION_STORE_OK); assert(Conversation_Store_Complete_Turn( - p_store, - conversation_id, - "request-4", - "Recovered answer", - 1, - 1) == CONVERSATION_STORE_OK); + p_store, conversation_id, "request-4", "Recovered answer", 1, 1) + == CONVERSATION_STORE_OK); p_arena = Dowa_Arena_Create(32 * 1024); assert(Conversation_Store_Get( p_store, conversation_id, &record, p_arena) == CONVERSATION_STORE_OK); assert(Dowa_Array_Length(record.turns) == 8); assert(strcmp(record.turns[5].status, "failed") == 0); - assert(strcmp( - record.turns[5].error_message, - "Interrupted by server restart") == 0); + assert(strcmp(record.turns[5].error_message, + "Interrupted by server restart") == 0); + Dowa_Arena_Free(p_arena); + + assert(Conversation_Store_Delete(p_store, conversation_id) + == CONVERSATION_STORE_OK); + p_arena = Dowa_Arena_Create(4096); + assert(Conversation_Store_Get(p_store, conversation_id, &record, p_arena) + == CONVERSATION_STORE_NOT_FOUND); + Dowa_Arena_Free(p_arena); + + Conversation_Store_Destroy(p_store); + printf(" legacy API tests PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Migration test: create a DB with old schema (no owner cols), then */ +/* re-open and verify migration ran, all rows preserved as legacy. */ +/* ------------------------------------------------------------------ */ + +static void test_migration_from_old_schema(void) +{ + char db_path[64]; + make_temp_db(db_path, sizeof(db_path)); + + /* Simulate an old-schema DB by inserting a row directly */ + Conversation_Store *p_store = Conversation_Store_Create(db_path); + assert(p_store); + + /* Create a conversation using the legacy API (will be owner_kind='legacy') */ + char legacy_id[37]; + assert(Conversation_Store_Create_Conversation( + p_store, "Old conversation", legacy_id) == CONVERSATION_STORE_OK); + Conversation_Store_Destroy(p_store); + + /* Re-open: migration should be idempotent */ + p_store = Conversation_Store_Create(db_path); + assert(p_store); + + /* Legacy row must still be accessible via legacy API */ + Dowa_Arena *p_arena = Dowa_Arena_Create(8192); + Conversation_Record record; + assert(Conversation_Store_Get( + p_store, legacy_id, &record, p_arena) == CONVERSATION_STORE_OK); + assert(strcmp(record.title, "Old conversation") == 0); + Dowa_Arena_Free(p_arena); + + /* Owner-aware GET must return NOT_FOUND for a user trying to access a legacy row */ + Conversation_Owner user_owner = {CONVERSATION_OWNER_KIND_USER, "user-uuid-abc-0000000000000000000000"}; + p_arena = Dowa_Arena_Create(4096); + assert(Conversation_Store_Get_Owned( + p_store, legacy_id, &user_owner, &record, p_arena) + == CONVERSATION_STORE_NOT_FOUND); + Dowa_Arena_Free(p_arena); + + Conversation_Store_Destroy(p_store); + unlink(db_path); + printf(" migration from old schema PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Owned CRUD and isolation tests */ +/* ------------------------------------------------------------------ */ + +static void test_owned_crud_and_isolation(void) +{ + char db_path[64]; + make_temp_db(db_path, sizeof(db_path)); + Conversation_Store *p_store = Conversation_Store_Create(db_path); + assert(p_store); + + Conversation_Owner user_a = {CONVERSATION_OWNER_KIND_USER, "aaaaaaaa-0000-0000-0000-000000000001"}; + Conversation_Owner user_b = {CONVERSATION_OWNER_KIND_USER, "bbbbbbbb-0000-0000-0000-000000000002"}; + Conversation_Owner guest_c = {CONVERSATION_OWNER_KIND_GUEST, "cccccccc-0000-0000-0000-000000000003"}; + + char id_a[37], id_b[37], id_c[37]; + assert(Conversation_Store_Create_Owned(p_store, "A's chat", &user_a, id_a) == CONVERSATION_STORE_OK); + assert(Conversation_Store_Create_Owned(p_store, "B's chat", &user_b, id_b) == CONVERSATION_STORE_OK); + assert(Conversation_Store_Create_Owned(p_store, "Guest C", &guest_c, id_c) == CONVERSATION_STORE_OK); + + /* Owner A can get their own */ + Dowa_Arena *p_arena = Dowa_Arena_Create(16 * 1024); + Conversation_Record record; + assert(Conversation_Store_Get_Owned( + p_store, id_a, &user_a, &record, p_arena) == CONVERSATION_STORE_OK); + assert(strcmp(record.title, "A's chat") == 0); + Dowa_Arena_Free(p_arena); + + /* Owner B cannot get A's conversation (returns NOT_FOUND, no leak) */ + p_arena = Dowa_Arena_Create(4096); + assert(Conversation_Store_Get_Owned( + p_store, id_a, &user_b, &record, p_arena) == CONVERSATION_STORE_NOT_FOUND); + Dowa_Arena_Free(p_arena); + + /* Guest C cannot get user A's conversation */ + p_arena = Dowa_Arena_Create(4096); + assert(Conversation_Store_Get_Owned( + p_store, id_a, &guest_c, &record, p_arena) == CONVERSATION_STORE_NOT_FOUND); Dowa_Arena_Free(p_arena); - assert(Conversation_Store_Delete( - p_store, conversation_id) == CONVERSATION_STORE_OK); + /* A can update their own title */ + assert(Conversation_Store_Update_Title_Owned( + p_store, id_a, &user_a, "A's renamed chat") == CONVERSATION_STORE_OK); + /* B cannot update A's title */ + assert(Conversation_Store_Update_Title_Owned( + p_store, id_a, &user_b, "B tries to rename") == CONVERSATION_STORE_NOT_FOUND); + + /* A can begin a turn */ + assert(Conversation_Store_Begin_Turn_Owned( + p_store, id_a, &user_a, "req-a-1", "Hello from A") == CONVERSATION_STORE_OK); + /* B cannot begin a turn on A's conversation */ + assert(Conversation_Store_Begin_Turn_Owned( + p_store, id_a, &user_b, "req-b-1", "B intrudes") == CONVERSATION_STORE_NOT_FOUND); + /* Complete the turn */ + assert(Conversation_Store_Complete_Turn( + p_store, id_a, "req-a-1", "Response for A", 5, 2) == CONVERSATION_STORE_OK); + + /* A can delete their own */ + assert(Conversation_Store_Delete_Owned(p_store, id_a, &user_a) == CONVERSATION_STORE_OK); + /* B cannot delete A's (now gone) conversation */ + assert(Conversation_Store_Delete_Owned(p_store, id_a, &user_b) == CONVERSATION_STORE_NOT_FOUND); + + /* Legacy creation is rejected */ + Conversation_Owner bad_legacy = {CONVERSATION_OWNER_KIND_LEGACY, ""}; + char throwaway[37]; + assert(Conversation_Store_Create_Owned( + p_store, "Bad", &bad_legacy, throwaway) == CONVERSATION_STORE_ERROR); + + Conversation_Store_Destroy(p_store); + unlink(db_path); + printf(" owned CRUD and isolation PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Paginated listing and cursor tests */ +/* ------------------------------------------------------------------ */ + +static void test_list_and_cursor(void) +{ + char db_path[64]; + make_temp_db(db_path, sizeof(db_path)); + Conversation_Store *p_store = Conversation_Store_Create(db_path); + assert(p_store); + + Conversation_Owner owner = {CONVERSATION_OWNER_KIND_USER, "11111111-0000-0000-0000-000000000001"}; + Conversation_Owner other = {CONVERSATION_OWNER_KIND_USER, "22222222-0000-0000-0000-000000000002"}; + + /* Create 5 conversations for owner, 1 for other */ + char ids[5][37]; + for (int i = 0; i < 5; i++) + assert(Conversation_Store_Create_Owned(p_store, "Chat", &owner, ids[i]) == CONVERSATION_STORE_OK); + char other_id[37]; + assert(Conversation_Store_Create_Owned(p_store, "Other chat", &other, other_id) == CONVERSATION_STORE_OK); + + /* List without cursor, limit 3 */ + Dowa_Arena *p_arena = Dowa_Arena_Create(32 * 1024); + Conversation_Summary *summaries = NULL; + int32 count = 0; + assert(Conversation_Store_List(p_store, &owner, 0, NULL, 3, &summaries, &count, p_arena) + == CONVERSATION_STORE_OK); + assert(count == 3); + Dowa_Arena_Free(p_arena); + + /* List all 5 with limit 10 */ + p_arena = Dowa_Arena_Create(32 * 1024); + assert(Conversation_Store_List(p_store, &owner, 0, NULL, 10, &summaries, &count, p_arena) + == CONVERSATION_STORE_OK); + assert(count == 5); + + /* Use cursor from end of first page */ + Conversation_Summary *last_first_page = NULL; + Dowa_Arena_Free(p_arena); + p_arena = Dowa_Arena_Create(32 * 1024); + assert(Conversation_Store_List(p_store, &owner, 0, NULL, 3, &summaries, &count, p_arena) + == CONVERSATION_STORE_OK); + assert(count == 3); + last_first_page = &summaries[2]; + int64 cur_ts = last_first_page->updated_at; + char cur_id[37]; + strncpy(cur_id, last_first_page->id, sizeof(cur_id) - 1); + cur_id[36] = '\0'; + Dowa_Arena_Free(p_arena); + + p_arena = Dowa_Arena_Create(32 * 1024); + assert(Conversation_Store_List(p_store, &owner, cur_ts, cur_id, 10, &summaries, &count, p_arena) + == CONVERSATION_STORE_OK); + assert(count == 2); + Dowa_Arena_Free(p_arena); + + /* Other owner's conversation is NOT in owner's list */ + p_arena = Dowa_Arena_Create(32 * 1024); + assert(Conversation_Store_List(p_store, &other, 0, NULL, 10, &summaries, &count, p_arena) + == CONVERSATION_STORE_OK); + assert(count == 1); + assert(strcmp(summaries[0].id, other_id) == 0); + Dowa_Arena_Free(p_arena); + + /* Legacy owner returns empty list */ + Conversation_Owner legacy_owner = {CONVERSATION_OWNER_KIND_LEGACY, ""}; p_arena = Dowa_Arena_Create(4096); - assert(Conversation_Store_Get( - p_store, - conversation_id, - &record, - p_arena) == CONVERSATION_STORE_NOT_FOUND); + assert(Conversation_Store_List(p_store, &legacy_owner, 0, NULL, 10, &summaries, &count, p_arena) + == CONVERSATION_STORE_OK); + assert(count == 0); + Dowa_Arena_Free(p_arena); + + /* Limit is capped at 50 */ + p_arena = Dowa_Arena_Create(32 * 1024); + assert(Conversation_Store_List(p_store, &owner, 0, NULL, 200, &summaries, &count, p_arena) + == CONVERSATION_STORE_OK); + assert(count == 5); /* only 5 exist, so < 50 */ Dowa_Arena_Free(p_arena); Conversation_Store_Destroy(p_store); - unlink(database_path); - printf("Conversation store tests passed\n"); + unlink(db_path); + printf(" list and cursor pagination PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Legacy claim tests */ +/* ------------------------------------------------------------------ */ + +static void test_claim_legacy(void) +{ + char db_path[64]; + make_temp_db(db_path, sizeof(db_path)); + Conversation_Store *p_store = Conversation_Store_Create(db_path); + assert(p_store); + + /* Create a legacy row via the legacy API */ + char legacy_id[37]; + assert(Conversation_Store_Create_Conversation( + p_store, "Unclaimed adventure", legacy_id) == CONVERSATION_STORE_OK); + + /* Claim as user */ + const char *user_id = "33333333-0000-0000-0000-000000000099"; + assert(Conversation_Store_Claim_Legacy(p_store, legacy_id, user_id) + == CONVERSATION_STORE_OK); + + /* Now accessible via owned API */ + Dowa_Arena *p_arena = Dowa_Arena_Create(8192); + Conversation_Record record; + Conversation_Owner claimed_owner = {CONVERSATION_OWNER_KIND_USER, {0}}; + strncpy(claimed_owner.id, user_id, sizeof(claimed_owner.id) - 1); + assert(Conversation_Store_Get_Owned( + p_store, legacy_id, &claimed_owner, &record, p_arena) + == CONVERSATION_STORE_OK); + assert(strcmp(record.title, "Unclaimed adventure") == 0); + Dowa_Arena_Free(p_arena); + + /* Cannot claim again (CONFLICT: already user-owned) */ + assert(Conversation_Store_Claim_Legacy(p_store, legacy_id, user_id) + == CONVERSATION_STORE_CONFLICT); + + /* Non-existent conversation returns NOT_FOUND */ + assert(Conversation_Store_Claim_Legacy( + p_store, "00000000-0000-0000-0000-000000000000", user_id) + == CONVERSATION_STORE_NOT_FOUND); + + Conversation_Store_Destroy(p_store); + unlink(db_path); + printf(" legacy claim PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Atomic/idempotent guest-to-user transfer test */ +/* ------------------------------------------------------------------ */ + +static void test_guest_transfer(void) +{ + char db_path[64]; + make_temp_db(db_path, sizeof(db_path)); + Conversation_Store *p_store = Conversation_Store_Create(db_path); + assert(p_store); + + const char *guest_id = "44444444-0000-0000-0000-000000000001"; + const char *user_id = "55555555-0000-0000-0000-000000000001"; + Conversation_Owner guest_owner = {CONVERSATION_OWNER_KIND_GUEST, {0}}; + Conversation_Owner user_owner = {CONVERSATION_OWNER_KIND_USER, {0}}; + strncpy(guest_owner.id, guest_id, 36); + strncpy(user_owner.id, user_id, 36); + + /* Create 3 guest conversations */ + char gid1[37], gid2[37], gid3[37]; + assert(Conversation_Store_Create_Owned(p_store, "Guest chat 1", &guest_owner, gid1) == CONVERSATION_STORE_OK); + assert(Conversation_Store_Create_Owned(p_store, "Guest chat 2", &guest_owner, gid2) == CONVERSATION_STORE_OK); + assert(Conversation_Store_Create_Owned(p_store, "Guest chat 3", &guest_owner, gid3) == CONVERSATION_STORE_OK); + + /* Transfer guest to user */ + assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_id) + == CONVERSATION_STORE_OK); + + /* All three now belong to user */ + Dowa_Arena *p_arena = Dowa_Arena_Create(8192); + Conversation_Record record; + assert(Conversation_Store_Get_Owned(p_store, gid1, &user_owner, &record, p_arena) == CONVERSATION_STORE_OK); + Dowa_Arena_Free(p_arena); + p_arena = Dowa_Arena_Create(8192); + assert(Conversation_Store_Get_Owned(p_store, gid2, &user_owner, &record, p_arena) == CONVERSATION_STORE_OK); + Dowa_Arena_Free(p_arena); + p_arena = Dowa_Arena_Create(8192); + assert(Conversation_Store_Get_Owned(p_store, gid3, &user_owner, &record, p_arena) == CONVERSATION_STORE_OK); + Dowa_Arena_Free(p_arena); + + /* Guest can no longer see them */ + p_arena = Dowa_Arena_Create(4096); + assert(Conversation_Store_Get_Owned(p_store, gid1, &guest_owner, &record, p_arena) == CONVERSATION_STORE_NOT_FOUND); + Dowa_Arena_Free(p_arena); + + /* Idempotent: running again succeeds (no-op) */ + assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_id) + == CONVERSATION_STORE_OK); + + Conversation_Store_Destroy(p_store); + unlink(db_path); + printf(" atomic/idempotent guest transfer PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Transfer-then-stale-guest-create: race-safety test */ +/* ------------------------------------------------------------------ */ + +static void test_transfer_stale_guest_create(void) +{ + char db_path[64]; + make_temp_db(db_path, sizeof(db_path)); + Conversation_Store *p_store = Conversation_Store_Create(db_path); + assert(p_store); + + const char *guest_id = "66666666-0000-0000-0000-000000000001"; + const char *user_id = "77777777-0000-0000-0000-000000000001"; + Conversation_Owner guest_owner = {CONVERSATION_OWNER_KIND_GUEST, {0}}; + Conversation_Owner user_owner = {CONVERSATION_OWNER_KIND_USER, {0}}; + strncpy(guest_owner.id, guest_id, 36); + strncpy(user_owner.id, user_id, 36); + + /* Create one conversation as guest before transfer */ + char pre_transfer_id[37]; + assert(Conversation_Store_Create_Owned(p_store, "Pre-transfer", &guest_owner, pre_transfer_id) + == CONVERSATION_STORE_OK); + + /* Transfer guest to user (records mapping) */ + assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_id) + == CONVERSATION_STORE_OK); + + /* Stale guest create: arrives after transfer (simulates race) */ + char stale_id[37]; + assert(Conversation_Store_Create_Owned(p_store, "Stale guest create", &guest_owner, stale_id) + == CONVERSATION_STORE_OK); + + /* The stale create must be owned by the mapped user, not the guest */ + Dowa_Arena *p_arena = Dowa_Arena_Create(8192); + Conversation_Record record; + /* User can see the stale-created conversation */ + assert(Conversation_Store_Get_Owned(p_store, stale_id, &user_owner, &record, p_arena) + == CONVERSATION_STORE_OK); + assert(strcmp(record.title, "Stale guest create") == 0); + Dowa_Arena_Free(p_arena); + + /* Guest can no longer see the stale-created conversation */ + p_arena = Dowa_Arena_Create(4096); + assert(Conversation_Store_Get_Owned(p_store, stale_id, &guest_owner, &record, p_arena) + == CONVERSATION_STORE_NOT_FOUND); + Dowa_Arena_Free(p_arena); + + /* Pre-transfer conversation also belongs to user */ + p_arena = Dowa_Arena_Create(8192); + assert(Conversation_Store_Get_Owned(p_store, pre_transfer_id, &user_owner, &record, p_arena) + == CONVERSATION_STORE_OK); + Dowa_Arena_Free(p_arena); + + Conversation_Store_Destroy(p_store); + unlink(db_path); + printf(" transfer then stale guest create → owned by user PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Conflicting transfer mapping test */ +/* ------------------------------------------------------------------ */ + +static void test_transfer_conflict(void) +{ + char db_path[64]; + make_temp_db(db_path, sizeof(db_path)); + Conversation_Store *p_store = Conversation_Store_Create(db_path); + assert(p_store); + + const char *guest_id = "88888888-0000-0000-0000-000000000001"; + const char *user_a_id = "aaaaaaaa-1111-0000-0000-000000000001"; + const char *user_b_id = "bbbbbbbb-2222-0000-0000-000000000001"; + + /* First transfer: guest → user A */ + assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_a_id) + == CONVERSATION_STORE_OK); + + /* Idempotent: same mapping again must succeed */ + assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_a_id) + == CONVERSATION_STORE_OK); + + /* Conflicting transfer: guest → user B must fail with CONFLICT */ + assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_b_id) + == CONVERSATION_STORE_CONFLICT); + + Conversation_Store_Destroy(p_store); + unlink(db_path); + printf(" conflicting transfer mapping returns CONFLICT PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Index DESC migration test */ +/* ------------------------------------------------------------------ */ + +static void test_index_desc_migration(void) +{ + char db_path[64]; + make_temp_db(db_path, sizeof(db_path)); + + /* Open twice to verify migration is idempotent */ + Conversation_Store *p_store = Conversation_Store_Create(db_path); + assert(p_store); + Conversation_Store_Destroy(p_store); + + /* Re-open: migrations must be idempotent */ + p_store = Conversation_Store_Create(db_path); + assert(p_store); + + /* Verify the listing still works (index is usable) after migration 3 */ + Conversation_Owner owner = {CONVERSATION_OWNER_KIND_USER, "cccccccc-3333-0000-0000-000000000001"}; + char id1[37], id2[37]; + assert(Conversation_Store_Create_Owned(p_store, "Alpha", &owner, id1) == CONVERSATION_STORE_OK); + assert(Conversation_Store_Create_Owned(p_store, "Beta", &owner, id2) == CONVERSATION_STORE_OK); + + Dowa_Arena *p_arena = Dowa_Arena_Create(32 * 1024); + Conversation_Summary *summaries = NULL; + int32 count = 0; + assert(Conversation_Store_List(p_store, &owner, 0, NULL, 10, &summaries, &count, p_arena) + == CONVERSATION_STORE_OK); + /* Both conversations present */ + assert(count == 2); + /* Ordering must be stable: both IDs must appear */ + assert( + (strcmp(summaries[0].id, id1) == 0 || strcmp(summaries[0].id, id2) == 0) && + (strcmp(summaries[1].id, id1) == 0 || strcmp(summaries[1].id, id2) == 0) && + strcmp(summaries[0].id, summaries[1].id) != 0); + Dowa_Arena_Free(p_arena); + + Conversation_Store_Destroy(p_store); + unlink(db_path); + printf(" index DESC migration applied and listing order correct PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Transfer_Atomic: conversations + quota cleared in one transaction */ +/* ------------------------------------------------------------------ */ + +/* + * We exercise Transfer_Guest_To_User_Atomic by inserting synthetic quota + * rows directly via a secondary connection (since the conversation store + * and auth store share the same SQLite file) and verifying that after the + * atomic transfer the reservation rows are gone and output_tokens_reserved + * is decremented. + */ +static void test_transfer_atomic_quota_cleanup(void) +{ + char db_path[64]; + make_temp_db(db_path, sizeof(db_path)); + + Conversation_Store *p_store = Conversation_Store_Create(db_path); + assert(p_store); + + /* Seed guest identity, usage, and reservation rows via a raw connection + * to simulate what the auth store would have written (in production both + * stores share the same SQLite file). */ + Deita_Connection *p_conn = + Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, db_path); + assert(p_conn); + Deita_Query_Execute_Update(p_conn, + "PRAGMA foreign_keys = OFF;" + "PRAGMA journal_mode = WAL;"); + + /* Create auth tables that share the same file in production. */ + Deita_Query_Execute_Update(p_conn, + "CREATE TABLE IF NOT EXISTS guest_identities (" + " id TEXT PRIMARY KEY," + " ip_binding_digest TEXT NOT NULL," + " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," + " last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," + " expires_at INTEGER NOT NULL" + ");"); + Deita_Query_Execute_Update(p_conn, + "CREATE TABLE IF NOT EXISTS guest_usage (" + " guest_id TEXT NOT NULL," + " window_start INTEGER NOT NULL," + " count INTEGER NOT NULL DEFAULT 0," + " turns_used INTEGER NOT NULL DEFAULT 0," + " output_tokens_used INTEGER NOT NULL DEFAULT 0," + " output_tokens_reserved INTEGER NOT NULL DEFAULT 0," + " PRIMARY KEY (guest_id, window_start)" + ");"); + Deita_Query_Execute_Update(p_conn, + "CREATE TABLE IF NOT EXISTS guest_usage_reservations (" + " request_id TEXT PRIMARY KEY," + " guest_id TEXT NOT NULL," + " window_start INTEGER NOT NULL," + " output_tokens_reserved INTEGER NOT NULL DEFAULT 0," + " reserved_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))," + " expires_at INTEGER NOT NULL" + ");"); + + const char *gid = "aaaabbbb-cccc-4000-8000-111111111111"; + const char *uid = "ddddeeee-ffff-4000-8000-222222222222"; + const char *rid1 = "rrrr1111-0000-4000-8000-000000000001"; + const char *rid2 = "rrrr2222-0000-4000-8000-000000000002"; + + /* guest_identities */ + const char *gid_p[] = {gid}; + Deita_Query_Execute_Update_Prepared(p_conn, + "INSERT OR IGNORE INTO guest_identities " + "(id, ip_binding_digest, expires_at) VALUES (?, 'x', 9999999999)", + 1, gid_p); + + /* guest_usage: 2 turns used, 300 tokens reserved */ + const char *gu_p[] = {gid}; + Deita_Query_Execute_Update_Prepared(p_conn, + "INSERT OR REPLACE INTO guest_usage " + "(guest_id, window_start, count, turns_used, output_tokens_used, " + " output_tokens_reserved) VALUES (?, 1700524800, 2, 2, 0, 300)", + 1, gu_p); + + /* Two reservation rows (100 + 200 = 300 total) */ + const char *r1_p[] = {rid1, gid}; + Deita_Query_Execute_Update_Prepared(p_conn, + "INSERT OR REPLACE INTO guest_usage_reservations " + "(request_id, guest_id, window_start, output_tokens_reserved, expires_at) " + "VALUES (?, ?, 1700524800, 100, 9999999999)", + 2, r1_p); + const char *r2_p[] = {rid2, gid}; + Deita_Query_Execute_Update_Prepared(p_conn, + "INSERT OR REPLACE INTO guest_usage_reservations " + "(request_id, guest_id, window_start, output_tokens_reserved, expires_at) " + "VALUES (?, ?, 1700524800, 200, 9999999999)", + 2, r2_p); + Deita_Connection_Close(p_conn); + + /* Create one guest conversation */ + Conversation_Owner g_owner = {CONVERSATION_OWNER_KIND_GUEST, ""}; + strncpy(g_owner.id, gid, 36); + char conv_id[37]; + assert(Conversation_Store_Create_Owned(p_store, "G conv", &g_owner, conv_id) + == CONVERSATION_STORE_OK); + + /* Atomic transfer */ + assert(Conversation_Store_Transfer_Guest_To_User_Atomic( + p_store, gid, uid) == CONVERSATION_STORE_OK); + + /* Verify: conversation now belongs to user */ + Conversation_Owner u_owner = {CONVERSATION_OWNER_KIND_USER, ""}; + strncpy(u_owner.id, uid, 36); + Dowa_Arena *p_arena = Dowa_Arena_Create(8 * 1024); + Conversation_Record rec; + assert(Conversation_Store_Get_Owned( + p_store, conv_id, &u_owner, &rec, p_arena) == CONVERSATION_STORE_OK); + Dowa_Arena_Free(p_arena); + + /* Verify: quota reservation rows deleted and output_tokens_reserved = 0 */ + Dowa_Arena *chk_arena = Dowa_Arena_Create(4096); + Deita_Connection *p_chk = + Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, db_path); + assert(p_chk); + Deita_Query_Execute_Update(p_chk, "PRAGMA journal_mode = WAL;"); + + const char *chk_gid[] = {gid}; + Deita_Result_Set *rs = Deita_Query_Execute_Prepared( + p_chk, + "SELECT output_tokens_reserved FROM guest_usage WHERE guest_id = ?", + 1, chk_gid, chk_arena); + assert(rs && Deita_Result_Set_Next(rs)); + assert(Deita_Result_Set_Get_Integer(rs, 0) == 0); + Deita_Result_Set_Free(rs); + + rs = Deita_Query_Execute_Prepared( + p_chk, + "SELECT COUNT(*) FROM guest_usage_reservations WHERE guest_id = ?", + 1, chk_gid, chk_arena); + assert(rs && Deita_Result_Set_Next(rs)); + assert(Deita_Result_Set_Get_Integer(rs, 0) == 0); + Deita_Result_Set_Free(rs); + + Deita_Connection_Close(p_chk); + Dowa_Arena_Free(chk_arena); + + /* Idempotent: re-run same transfer is a no-op */ + assert(Conversation_Store_Transfer_Guest_To_User_Atomic( + p_store, gid, uid) == CONVERSATION_STORE_OK); + + Conversation_Store_Destroy(p_store); + unlink(db_path); + printf(" transfer_atomic_quota_cleanup PASS\n"); +} + +int main(void) +{ + printf("conversation_store tests:\n"); + + char legacy_db[64]; + make_temp_db(legacy_db, sizeof(legacy_db)); + test_legacy_api(legacy_db); + unlink(legacy_db); + + test_migration_from_old_schema(); + test_owned_crud_and_isolation(); + test_list_and_cursor(); + test_claim_legacy(); + test_guest_transfer(); + test_transfer_stale_guest_create(); + test_transfer_conflict(); + test_index_desc_migration(); + test_transfer_atomic_quota_cleanup(); + + printf("All conversation store tests passed\n"); return 0; }