view mrjunejune/test/conversation_store_test.c @ 273:e02e2036ef84 default tip

add Layer 2 JRPG component system Add reusable content and window modals, an isolated component sandbox, shared cyberpunk scroll areas, production-safe cache freshness, and server-rendered JRPG panel state. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Sat, 08 Aug 2026 02:08:08 -0700
parents 04fee26ecce0
children
line wrap: on
line source

#include "mrjunejune/conversation_store.h"
#include "deita/deita.h"

#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

/* Helper to create and fill a temp DB file path */
static void make_temp_db(char *out, size_t cap)
{
  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);

  char conversation_id[37];
  assert(Conversation_Store_Create_Conversation(
      p_store, "First quest", conversation_id) == CONVERSATION_STORE_OK);
  assert(strlen(conversation_id) == 36);

  Dowa_Arena *p_arena = Dowa_Arena_Create(16 * 1024);
  Conversation_Record record;
  assert(Conversation_Store_Get(
      p_store, conversation_id, &record, p_arena) == CONVERSATION_STORE_OK);
  assert(strcmp(record.id, conversation_id) == 0);
  assert(strcmp(record.title, "First quest") == 0);
  assert(Dowa_Array_Length(record.turns) == 0);
  Dowa_Arena_Free(p_arena);

  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);
  assert(Conversation_Store_Begin_Turn(
      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);

  assert(Conversation_Store_Begin_Turn(
      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_arena = Dowa_Arena_Create(32 * 1024);
  assert(Conversation_Store_Get(
      p_store, conversation_id, &record, p_arena) == CONVERSATION_STORE_OK);
  assert(strcmp(record.title, "Renamed quest") == 0);
  assert(Dowa_Array_Length(record.turns) == 4);
  assert(strcmp(record.turns[0].role, "user") == 0);
  assert(strcmp(record.turns[0].content, "Hello Epi") == 0);
  assert(strcmp(record.turns[1].role, "assistant") == 0);
  assert(strcmp(record.turns[1].content, "Hello traveler") == 0);
  assert(record.turns[1].input_tokens == 12);
  assert(record.turns[1].output_tokens == 4);
  assert(strcmp(record.turns[3].status, "aborted") == 0);
  Dowa_Arena_Free(p_arena);

  assert(Conversation_Store_Begin_Turn(
      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);
  assert(Conversation_Store_Complete_Turn(
      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);
  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);

  /* 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_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(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;
}