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;
 }