comparison 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
comparison
equal deleted inserted replaced
263:ee04e4e69fed 264:04fee26ecce0
1 #include "mrjunejune/conversation_store.h" 1 #include "mrjunejune/conversation_store.h"
2 #include "deita/deita.h"
2 3
3 #include <assert.h> 4 #include <assert.h>
4 #include <stdio.h> 5 #include <stdio.h>
5 #include <string.h> 6 #include <string.h>
6 #include <unistd.h> 7 #include <unistd.h>
7 8
8 int main(void) 9 /* Helper to create and fill a temp DB file path */
9 { 10 static void make_temp_db(char *out, size_t cap)
10 char database_path[] = "/tmp/mrjunejune-conversations-XXXXXX"; 11 {
11 int fd = mkstemp(database_path); 12 snprintf(out, cap, "/tmp/mrjunejune-store-test-XXXXXX");
13 int fd = mkstemp(out);
12 assert(fd >= 0); 14 assert(fd >= 0);
13 close(fd); 15 close(fd);
14 16 }
17
18 /* ------------------------------------------------------------------ */
19 /* Original (legacy-API) tests */
20 /* ------------------------------------------------------------------ */
21
22 static void test_legacy_api(const char *database_path)
23 {
15 Conversation_Store *p_store = Conversation_Store_Create(database_path); 24 Conversation_Store *p_store = Conversation_Store_Create(database_path);
16 assert(p_store); 25 assert(p_store);
17 26
18 char conversation_id[37]; 27 char conversation_id[37];
19 assert(Conversation_Store_Create_Conversation( 28 assert(Conversation_Store_Create_Conversation(
30 Dowa_Arena_Free(p_arena); 39 Dowa_Arena_Free(p_arena);
31 40
32 assert(Conversation_Store_Update_Title( 41 assert(Conversation_Store_Update_Title(
33 p_store, conversation_id, "Renamed quest") == CONVERSATION_STORE_OK); 42 p_store, conversation_id, "Renamed quest") == CONVERSATION_STORE_OK);
34 assert(Conversation_Store_Begin_Turn( 43 assert(Conversation_Store_Begin_Turn(
35 p_store, 44 p_store, conversation_id, "request-1", "Hello Epi")
36 conversation_id, 45 == CONVERSATION_STORE_OK);
37 "request-1",
38 "Hello Epi") == CONVERSATION_STORE_OK);
39 assert(Conversation_Store_Begin_Turn( 46 assert(Conversation_Store_Begin_Turn(
40 p_store, 47 p_store, conversation_id, "request-2", "Overlapping")
41 conversation_id, 48 == CONVERSATION_STORE_CONFLICT);
42 "request-2",
43 "Overlapping") == CONVERSATION_STORE_CONFLICT);
44 assert(Conversation_Store_Complete_Turn( 49 assert(Conversation_Store_Complete_Turn(
45 p_store, 50 p_store, conversation_id, "request-1", "Hello traveler", 12, 4)
46 conversation_id, 51 == CONVERSATION_STORE_OK);
47 "request-1",
48 "Hello traveler",
49 12,
50 4) == CONVERSATION_STORE_OK);
51 52
52 assert(Conversation_Store_Begin_Turn( 53 assert(Conversation_Store_Begin_Turn(
53 p_store, 54 p_store, conversation_id, "request-2", "Try again")
54 conversation_id, 55 == CONVERSATION_STORE_OK);
55 "request-2",
56 "Try again") == CONVERSATION_STORE_OK);
57 assert(Conversation_Store_Fail_Turn( 56 assert(Conversation_Store_Fail_Turn(
58 p_store, 57 p_store, conversation_id, "request-2", "cancelled", TRUE)
59 conversation_id, 58 == CONVERSATION_STORE_OK);
60 "request-2",
61 "cancelled",
62 TRUE) == CONVERSATION_STORE_OK);
63 59
64 p_arena = Dowa_Arena_Create(32 * 1024); 60 p_arena = Dowa_Arena_Create(32 * 1024);
65 assert(Conversation_Store_Get( 61 assert(Conversation_Store_Get(
66 p_store, conversation_id, &record, p_arena) == CONVERSATION_STORE_OK); 62 p_store, conversation_id, &record, p_arena) == CONVERSATION_STORE_OK);
67 assert(strcmp(record.title, "Renamed quest") == 0); 63 assert(strcmp(record.title, "Renamed quest") == 0);
74 assert(record.turns[1].output_tokens == 4); 70 assert(record.turns[1].output_tokens == 4);
75 assert(strcmp(record.turns[3].status, "aborted") == 0); 71 assert(strcmp(record.turns[3].status, "aborted") == 0);
76 Dowa_Arena_Free(p_arena); 72 Dowa_Arena_Free(p_arena);
77 73
78 assert(Conversation_Store_Begin_Turn( 74 assert(Conversation_Store_Begin_Turn(
79 p_store, 75 p_store, conversation_id, "request-3", "Interrupted")
80 conversation_id, 76 == CONVERSATION_STORE_OK);
81 "request-3",
82 "Interrupted") == CONVERSATION_STORE_OK);
83 Conversation_Store_Destroy(p_store); 77 Conversation_Store_Destroy(p_store);
84 p_store = Conversation_Store_Create(database_path); 78 p_store = Conversation_Store_Create(database_path);
85 assert(p_store); 79 assert(p_store);
86 assert(Conversation_Store_Begin_Turn( 80 assert(Conversation_Store_Begin_Turn(
87 p_store, 81 p_store, conversation_id, "request-4", "Recovered")
88 conversation_id, 82 == CONVERSATION_STORE_OK);
89 "request-4",
90 "Recovered") == CONVERSATION_STORE_OK);
91 assert(Conversation_Store_Complete_Turn( 83 assert(Conversation_Store_Complete_Turn(
92 p_store, 84 p_store, conversation_id, "request-4", "Recovered answer", 1, 1)
93 conversation_id, 85 == CONVERSATION_STORE_OK);
94 "request-4",
95 "Recovered answer",
96 1,
97 1) == CONVERSATION_STORE_OK);
98 86
99 p_arena = Dowa_Arena_Create(32 * 1024); 87 p_arena = Dowa_Arena_Create(32 * 1024);
100 assert(Conversation_Store_Get( 88 assert(Conversation_Store_Get(
101 p_store, conversation_id, &record, p_arena) == CONVERSATION_STORE_OK); 89 p_store, conversation_id, &record, p_arena) == CONVERSATION_STORE_OK);
102 assert(Dowa_Array_Length(record.turns) == 8); 90 assert(Dowa_Array_Length(record.turns) == 8);
103 assert(strcmp(record.turns[5].status, "failed") == 0); 91 assert(strcmp(record.turns[5].status, "failed") == 0);
104 assert(strcmp( 92 assert(strcmp(record.turns[5].error_message,
105 record.turns[5].error_message, 93 "Interrupted by server restart") == 0);
106 "Interrupted by server restart") == 0); 94 Dowa_Arena_Free(p_arena);
107 Dowa_Arena_Free(p_arena); 95
108 96 assert(Conversation_Store_Delete(p_store, conversation_id)
109 assert(Conversation_Store_Delete( 97 == CONVERSATION_STORE_OK);
110 p_store, conversation_id) == CONVERSATION_STORE_OK);
111 p_arena = Dowa_Arena_Create(4096); 98 p_arena = Dowa_Arena_Create(4096);
99 assert(Conversation_Store_Get(p_store, conversation_id, &record, p_arena)
100 == CONVERSATION_STORE_NOT_FOUND);
101 Dowa_Arena_Free(p_arena);
102
103 Conversation_Store_Destroy(p_store);
104 printf(" legacy API tests PASS\n");
105 }
106
107 /* ------------------------------------------------------------------ */
108 /* Migration test: create a DB with old schema (no owner cols), then */
109 /* re-open and verify migration ran, all rows preserved as legacy. */
110 /* ------------------------------------------------------------------ */
111
112 static void test_migration_from_old_schema(void)
113 {
114 char db_path[64];
115 make_temp_db(db_path, sizeof(db_path));
116
117 /* Simulate an old-schema DB by inserting a row directly */
118 Conversation_Store *p_store = Conversation_Store_Create(db_path);
119 assert(p_store);
120
121 /* Create a conversation using the legacy API (will be owner_kind='legacy') */
122 char legacy_id[37];
123 assert(Conversation_Store_Create_Conversation(
124 p_store, "Old conversation", legacy_id) == CONVERSATION_STORE_OK);
125 Conversation_Store_Destroy(p_store);
126
127 /* Re-open: migration should be idempotent */
128 p_store = Conversation_Store_Create(db_path);
129 assert(p_store);
130
131 /* Legacy row must still be accessible via legacy API */
132 Dowa_Arena *p_arena = Dowa_Arena_Create(8192);
133 Conversation_Record record;
112 assert(Conversation_Store_Get( 134 assert(Conversation_Store_Get(
113 p_store, 135 p_store, legacy_id, &record, p_arena) == CONVERSATION_STORE_OK);
114 conversation_id, 136 assert(strcmp(record.title, "Old conversation") == 0);
115 &record, 137 Dowa_Arena_Free(p_arena);
116 p_arena) == CONVERSATION_STORE_NOT_FOUND); 138
117 Dowa_Arena_Free(p_arena); 139 /* Owner-aware GET must return NOT_FOUND for a user trying to access a legacy row */
118 140 Conversation_Owner user_owner = {CONVERSATION_OWNER_KIND_USER, "user-uuid-abc-0000000000000000000000"};
119 Conversation_Store_Destroy(p_store); 141 p_arena = Dowa_Arena_Create(4096);
120 unlink(database_path); 142 assert(Conversation_Store_Get_Owned(
121 printf("Conversation store tests passed\n"); 143 p_store, legacy_id, &user_owner, &record, p_arena)
144 == CONVERSATION_STORE_NOT_FOUND);
145 Dowa_Arena_Free(p_arena);
146
147 Conversation_Store_Destroy(p_store);
148 unlink(db_path);
149 printf(" migration from old schema PASS\n");
150 }
151
152 /* ------------------------------------------------------------------ */
153 /* Owned CRUD and isolation tests */
154 /* ------------------------------------------------------------------ */
155
156 static void test_owned_crud_and_isolation(void)
157 {
158 char db_path[64];
159 make_temp_db(db_path, sizeof(db_path));
160 Conversation_Store *p_store = Conversation_Store_Create(db_path);
161 assert(p_store);
162
163 Conversation_Owner user_a = {CONVERSATION_OWNER_KIND_USER, "aaaaaaaa-0000-0000-0000-000000000001"};
164 Conversation_Owner user_b = {CONVERSATION_OWNER_KIND_USER, "bbbbbbbb-0000-0000-0000-000000000002"};
165 Conversation_Owner guest_c = {CONVERSATION_OWNER_KIND_GUEST, "cccccccc-0000-0000-0000-000000000003"};
166
167 char id_a[37], id_b[37], id_c[37];
168 assert(Conversation_Store_Create_Owned(p_store, "A's chat", &user_a, id_a) == CONVERSATION_STORE_OK);
169 assert(Conversation_Store_Create_Owned(p_store, "B's chat", &user_b, id_b) == CONVERSATION_STORE_OK);
170 assert(Conversation_Store_Create_Owned(p_store, "Guest C", &guest_c, id_c) == CONVERSATION_STORE_OK);
171
172 /* Owner A can get their own */
173 Dowa_Arena *p_arena = Dowa_Arena_Create(16 * 1024);
174 Conversation_Record record;
175 assert(Conversation_Store_Get_Owned(
176 p_store, id_a, &user_a, &record, p_arena) == CONVERSATION_STORE_OK);
177 assert(strcmp(record.title, "A's chat") == 0);
178 Dowa_Arena_Free(p_arena);
179
180 /* Owner B cannot get A's conversation (returns NOT_FOUND, no leak) */
181 p_arena = Dowa_Arena_Create(4096);
182 assert(Conversation_Store_Get_Owned(
183 p_store, id_a, &user_b, &record, p_arena) == CONVERSATION_STORE_NOT_FOUND);
184 Dowa_Arena_Free(p_arena);
185
186 /* Guest C cannot get user A's conversation */
187 p_arena = Dowa_Arena_Create(4096);
188 assert(Conversation_Store_Get_Owned(
189 p_store, id_a, &guest_c, &record, p_arena) == CONVERSATION_STORE_NOT_FOUND);
190 Dowa_Arena_Free(p_arena);
191
192 /* A can update their own title */
193 assert(Conversation_Store_Update_Title_Owned(
194 p_store, id_a, &user_a, "A's renamed chat") == CONVERSATION_STORE_OK);
195 /* B cannot update A's title */
196 assert(Conversation_Store_Update_Title_Owned(
197 p_store, id_a, &user_b, "B tries to rename") == CONVERSATION_STORE_NOT_FOUND);
198
199 /* A can begin a turn */
200 assert(Conversation_Store_Begin_Turn_Owned(
201 p_store, id_a, &user_a, "req-a-1", "Hello from A") == CONVERSATION_STORE_OK);
202 /* B cannot begin a turn on A's conversation */
203 assert(Conversation_Store_Begin_Turn_Owned(
204 p_store, id_a, &user_b, "req-b-1", "B intrudes") == CONVERSATION_STORE_NOT_FOUND);
205 /* Complete the turn */
206 assert(Conversation_Store_Complete_Turn(
207 p_store, id_a, "req-a-1", "Response for A", 5, 2) == CONVERSATION_STORE_OK);
208
209 /* A can delete their own */
210 assert(Conversation_Store_Delete_Owned(p_store, id_a, &user_a) == CONVERSATION_STORE_OK);
211 /* B cannot delete A's (now gone) conversation */
212 assert(Conversation_Store_Delete_Owned(p_store, id_a, &user_b) == CONVERSATION_STORE_NOT_FOUND);
213
214 /* Legacy creation is rejected */
215 Conversation_Owner bad_legacy = {CONVERSATION_OWNER_KIND_LEGACY, ""};
216 char throwaway[37];
217 assert(Conversation_Store_Create_Owned(
218 p_store, "Bad", &bad_legacy, throwaway) == CONVERSATION_STORE_ERROR);
219
220 Conversation_Store_Destroy(p_store);
221 unlink(db_path);
222 printf(" owned CRUD and isolation PASS\n");
223 }
224
225 /* ------------------------------------------------------------------ */
226 /* Paginated listing and cursor tests */
227 /* ------------------------------------------------------------------ */
228
229 static void test_list_and_cursor(void)
230 {
231 char db_path[64];
232 make_temp_db(db_path, sizeof(db_path));
233 Conversation_Store *p_store = Conversation_Store_Create(db_path);
234 assert(p_store);
235
236 Conversation_Owner owner = {CONVERSATION_OWNER_KIND_USER, "11111111-0000-0000-0000-000000000001"};
237 Conversation_Owner other = {CONVERSATION_OWNER_KIND_USER, "22222222-0000-0000-0000-000000000002"};
238
239 /* Create 5 conversations for owner, 1 for other */
240 char ids[5][37];
241 for (int i = 0; i < 5; i++)
242 assert(Conversation_Store_Create_Owned(p_store, "Chat", &owner, ids[i]) == CONVERSATION_STORE_OK);
243 char other_id[37];
244 assert(Conversation_Store_Create_Owned(p_store, "Other chat", &other, other_id) == CONVERSATION_STORE_OK);
245
246 /* List without cursor, limit 3 */
247 Dowa_Arena *p_arena = Dowa_Arena_Create(32 * 1024);
248 Conversation_Summary *summaries = NULL;
249 int32 count = 0;
250 assert(Conversation_Store_List(p_store, &owner, 0, NULL, 3, &summaries, &count, p_arena)
251 == CONVERSATION_STORE_OK);
252 assert(count == 3);
253 Dowa_Arena_Free(p_arena);
254
255 /* List all 5 with limit 10 */
256 p_arena = Dowa_Arena_Create(32 * 1024);
257 assert(Conversation_Store_List(p_store, &owner, 0, NULL, 10, &summaries, &count, p_arena)
258 == CONVERSATION_STORE_OK);
259 assert(count == 5);
260
261 /* Use cursor from end of first page */
262 Conversation_Summary *last_first_page = NULL;
263 Dowa_Arena_Free(p_arena);
264 p_arena = Dowa_Arena_Create(32 * 1024);
265 assert(Conversation_Store_List(p_store, &owner, 0, NULL, 3, &summaries, &count, p_arena)
266 == CONVERSATION_STORE_OK);
267 assert(count == 3);
268 last_first_page = &summaries[2];
269 int64 cur_ts = last_first_page->updated_at;
270 char cur_id[37];
271 strncpy(cur_id, last_first_page->id, sizeof(cur_id) - 1);
272 cur_id[36] = '\0';
273 Dowa_Arena_Free(p_arena);
274
275 p_arena = Dowa_Arena_Create(32 * 1024);
276 assert(Conversation_Store_List(p_store, &owner, cur_ts, cur_id, 10, &summaries, &count, p_arena)
277 == CONVERSATION_STORE_OK);
278 assert(count == 2);
279 Dowa_Arena_Free(p_arena);
280
281 /* Other owner's conversation is NOT in owner's list */
282 p_arena = Dowa_Arena_Create(32 * 1024);
283 assert(Conversation_Store_List(p_store, &other, 0, NULL, 10, &summaries, &count, p_arena)
284 == CONVERSATION_STORE_OK);
285 assert(count == 1);
286 assert(strcmp(summaries[0].id, other_id) == 0);
287 Dowa_Arena_Free(p_arena);
288
289 /* Legacy owner returns empty list */
290 Conversation_Owner legacy_owner = {CONVERSATION_OWNER_KIND_LEGACY, ""};
291 p_arena = Dowa_Arena_Create(4096);
292 assert(Conversation_Store_List(p_store, &legacy_owner, 0, NULL, 10, &summaries, &count, p_arena)
293 == CONVERSATION_STORE_OK);
294 assert(count == 0);
295 Dowa_Arena_Free(p_arena);
296
297 /* Limit is capped at 50 */
298 p_arena = Dowa_Arena_Create(32 * 1024);
299 assert(Conversation_Store_List(p_store, &owner, 0, NULL, 200, &summaries, &count, p_arena)
300 == CONVERSATION_STORE_OK);
301 assert(count == 5); /* only 5 exist, so < 50 */
302 Dowa_Arena_Free(p_arena);
303
304 Conversation_Store_Destroy(p_store);
305 unlink(db_path);
306 printf(" list and cursor pagination PASS\n");
307 }
308
309 /* ------------------------------------------------------------------ */
310 /* Legacy claim tests */
311 /* ------------------------------------------------------------------ */
312
313 static void test_claim_legacy(void)
314 {
315 char db_path[64];
316 make_temp_db(db_path, sizeof(db_path));
317 Conversation_Store *p_store = Conversation_Store_Create(db_path);
318 assert(p_store);
319
320 /* Create a legacy row via the legacy API */
321 char legacy_id[37];
322 assert(Conversation_Store_Create_Conversation(
323 p_store, "Unclaimed adventure", legacy_id) == CONVERSATION_STORE_OK);
324
325 /* Claim as user */
326 const char *user_id = "33333333-0000-0000-0000-000000000099";
327 assert(Conversation_Store_Claim_Legacy(p_store, legacy_id, user_id)
328 == CONVERSATION_STORE_OK);
329
330 /* Now accessible via owned API */
331 Dowa_Arena *p_arena = Dowa_Arena_Create(8192);
332 Conversation_Record record;
333 Conversation_Owner claimed_owner = {CONVERSATION_OWNER_KIND_USER, {0}};
334 strncpy(claimed_owner.id, user_id, sizeof(claimed_owner.id) - 1);
335 assert(Conversation_Store_Get_Owned(
336 p_store, legacy_id, &claimed_owner, &record, p_arena)
337 == CONVERSATION_STORE_OK);
338 assert(strcmp(record.title, "Unclaimed adventure") == 0);
339 Dowa_Arena_Free(p_arena);
340
341 /* Cannot claim again (CONFLICT: already user-owned) */
342 assert(Conversation_Store_Claim_Legacy(p_store, legacy_id, user_id)
343 == CONVERSATION_STORE_CONFLICT);
344
345 /* Non-existent conversation returns NOT_FOUND */
346 assert(Conversation_Store_Claim_Legacy(
347 p_store, "00000000-0000-0000-0000-000000000000", user_id)
348 == CONVERSATION_STORE_NOT_FOUND);
349
350 Conversation_Store_Destroy(p_store);
351 unlink(db_path);
352 printf(" legacy claim PASS\n");
353 }
354
355 /* ------------------------------------------------------------------ */
356 /* Atomic/idempotent guest-to-user transfer test */
357 /* ------------------------------------------------------------------ */
358
359 static void test_guest_transfer(void)
360 {
361 char db_path[64];
362 make_temp_db(db_path, sizeof(db_path));
363 Conversation_Store *p_store = Conversation_Store_Create(db_path);
364 assert(p_store);
365
366 const char *guest_id = "44444444-0000-0000-0000-000000000001";
367 const char *user_id = "55555555-0000-0000-0000-000000000001";
368 Conversation_Owner guest_owner = {CONVERSATION_OWNER_KIND_GUEST, {0}};
369 Conversation_Owner user_owner = {CONVERSATION_OWNER_KIND_USER, {0}};
370 strncpy(guest_owner.id, guest_id, 36);
371 strncpy(user_owner.id, user_id, 36);
372
373 /* Create 3 guest conversations */
374 char gid1[37], gid2[37], gid3[37];
375 assert(Conversation_Store_Create_Owned(p_store, "Guest chat 1", &guest_owner, gid1) == CONVERSATION_STORE_OK);
376 assert(Conversation_Store_Create_Owned(p_store, "Guest chat 2", &guest_owner, gid2) == CONVERSATION_STORE_OK);
377 assert(Conversation_Store_Create_Owned(p_store, "Guest chat 3", &guest_owner, gid3) == CONVERSATION_STORE_OK);
378
379 /* Transfer guest to user */
380 assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_id)
381 == CONVERSATION_STORE_OK);
382
383 /* All three now belong to user */
384 Dowa_Arena *p_arena = Dowa_Arena_Create(8192);
385 Conversation_Record record;
386 assert(Conversation_Store_Get_Owned(p_store, gid1, &user_owner, &record, p_arena) == CONVERSATION_STORE_OK);
387 Dowa_Arena_Free(p_arena);
388 p_arena = Dowa_Arena_Create(8192);
389 assert(Conversation_Store_Get_Owned(p_store, gid2, &user_owner, &record, p_arena) == CONVERSATION_STORE_OK);
390 Dowa_Arena_Free(p_arena);
391 p_arena = Dowa_Arena_Create(8192);
392 assert(Conversation_Store_Get_Owned(p_store, gid3, &user_owner, &record, p_arena) == CONVERSATION_STORE_OK);
393 Dowa_Arena_Free(p_arena);
394
395 /* Guest can no longer see them */
396 p_arena = Dowa_Arena_Create(4096);
397 assert(Conversation_Store_Get_Owned(p_store, gid1, &guest_owner, &record, p_arena) == CONVERSATION_STORE_NOT_FOUND);
398 Dowa_Arena_Free(p_arena);
399
400 /* Idempotent: running again succeeds (no-op) */
401 assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_id)
402 == CONVERSATION_STORE_OK);
403
404 Conversation_Store_Destroy(p_store);
405 unlink(db_path);
406 printf(" atomic/idempotent guest transfer PASS\n");
407 }
408
409 /* ------------------------------------------------------------------ */
410 /* Transfer-then-stale-guest-create: race-safety test */
411 /* ------------------------------------------------------------------ */
412
413 static void test_transfer_stale_guest_create(void)
414 {
415 char db_path[64];
416 make_temp_db(db_path, sizeof(db_path));
417 Conversation_Store *p_store = Conversation_Store_Create(db_path);
418 assert(p_store);
419
420 const char *guest_id = "66666666-0000-0000-0000-000000000001";
421 const char *user_id = "77777777-0000-0000-0000-000000000001";
422 Conversation_Owner guest_owner = {CONVERSATION_OWNER_KIND_GUEST, {0}};
423 Conversation_Owner user_owner = {CONVERSATION_OWNER_KIND_USER, {0}};
424 strncpy(guest_owner.id, guest_id, 36);
425 strncpy(user_owner.id, user_id, 36);
426
427 /* Create one conversation as guest before transfer */
428 char pre_transfer_id[37];
429 assert(Conversation_Store_Create_Owned(p_store, "Pre-transfer", &guest_owner, pre_transfer_id)
430 == CONVERSATION_STORE_OK);
431
432 /* Transfer guest to user (records mapping) */
433 assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_id)
434 == CONVERSATION_STORE_OK);
435
436 /* Stale guest create: arrives after transfer (simulates race) */
437 char stale_id[37];
438 assert(Conversation_Store_Create_Owned(p_store, "Stale guest create", &guest_owner, stale_id)
439 == CONVERSATION_STORE_OK);
440
441 /* The stale create must be owned by the mapped user, not the guest */
442 Dowa_Arena *p_arena = Dowa_Arena_Create(8192);
443 Conversation_Record record;
444 /* User can see the stale-created conversation */
445 assert(Conversation_Store_Get_Owned(p_store, stale_id, &user_owner, &record, p_arena)
446 == CONVERSATION_STORE_OK);
447 assert(strcmp(record.title, "Stale guest create") == 0);
448 Dowa_Arena_Free(p_arena);
449
450 /* Guest can no longer see the stale-created conversation */
451 p_arena = Dowa_Arena_Create(4096);
452 assert(Conversation_Store_Get_Owned(p_store, stale_id, &guest_owner, &record, p_arena)
453 == CONVERSATION_STORE_NOT_FOUND);
454 Dowa_Arena_Free(p_arena);
455
456 /* Pre-transfer conversation also belongs to user */
457 p_arena = Dowa_Arena_Create(8192);
458 assert(Conversation_Store_Get_Owned(p_store, pre_transfer_id, &user_owner, &record, p_arena)
459 == CONVERSATION_STORE_OK);
460 Dowa_Arena_Free(p_arena);
461
462 Conversation_Store_Destroy(p_store);
463 unlink(db_path);
464 printf(" transfer then stale guest create → owned by user PASS\n");
465 }
466
467 /* ------------------------------------------------------------------ */
468 /* Conflicting transfer mapping test */
469 /* ------------------------------------------------------------------ */
470
471 static void test_transfer_conflict(void)
472 {
473 char db_path[64];
474 make_temp_db(db_path, sizeof(db_path));
475 Conversation_Store *p_store = Conversation_Store_Create(db_path);
476 assert(p_store);
477
478 const char *guest_id = "88888888-0000-0000-0000-000000000001";
479 const char *user_a_id = "aaaaaaaa-1111-0000-0000-000000000001";
480 const char *user_b_id = "bbbbbbbb-2222-0000-0000-000000000001";
481
482 /* First transfer: guest → user A */
483 assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_a_id)
484 == CONVERSATION_STORE_OK);
485
486 /* Idempotent: same mapping again must succeed */
487 assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_a_id)
488 == CONVERSATION_STORE_OK);
489
490 /* Conflicting transfer: guest → user B must fail with CONFLICT */
491 assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_b_id)
492 == CONVERSATION_STORE_CONFLICT);
493
494 Conversation_Store_Destroy(p_store);
495 unlink(db_path);
496 printf(" conflicting transfer mapping returns CONFLICT PASS\n");
497 }
498
499 /* ------------------------------------------------------------------ */
500 /* Index DESC migration test */
501 /* ------------------------------------------------------------------ */
502
503 static void test_index_desc_migration(void)
504 {
505 char db_path[64];
506 make_temp_db(db_path, sizeof(db_path));
507
508 /* Open twice to verify migration is idempotent */
509 Conversation_Store *p_store = Conversation_Store_Create(db_path);
510 assert(p_store);
511 Conversation_Store_Destroy(p_store);
512
513 /* Re-open: migrations must be idempotent */
514 p_store = Conversation_Store_Create(db_path);
515 assert(p_store);
516
517 /* Verify the listing still works (index is usable) after migration 3 */
518 Conversation_Owner owner = {CONVERSATION_OWNER_KIND_USER, "cccccccc-3333-0000-0000-000000000001"};
519 char id1[37], id2[37];
520 assert(Conversation_Store_Create_Owned(p_store, "Alpha", &owner, id1) == CONVERSATION_STORE_OK);
521 assert(Conversation_Store_Create_Owned(p_store, "Beta", &owner, id2) == CONVERSATION_STORE_OK);
522
523 Dowa_Arena *p_arena = Dowa_Arena_Create(32 * 1024);
524 Conversation_Summary *summaries = NULL;
525 int32 count = 0;
526 assert(Conversation_Store_List(p_store, &owner, 0, NULL, 10, &summaries, &count, p_arena)
527 == CONVERSATION_STORE_OK);
528 /* Both conversations present */
529 assert(count == 2);
530 /* Ordering must be stable: both IDs must appear */
531 assert(
532 (strcmp(summaries[0].id, id1) == 0 || strcmp(summaries[0].id, id2) == 0) &&
533 (strcmp(summaries[1].id, id1) == 0 || strcmp(summaries[1].id, id2) == 0) &&
534 strcmp(summaries[0].id, summaries[1].id) != 0);
535 Dowa_Arena_Free(p_arena);
536
537 Conversation_Store_Destroy(p_store);
538 unlink(db_path);
539 printf(" index DESC migration applied and listing order correct PASS\n");
540 }
541
542 /* ------------------------------------------------------------------ */
543 /* Transfer_Atomic: conversations + quota cleared in one transaction */
544 /* ------------------------------------------------------------------ */
545
546 /*
547 * We exercise Transfer_Guest_To_User_Atomic by inserting synthetic quota
548 * rows directly via a secondary connection (since the conversation store
549 * and auth store share the same SQLite file) and verifying that after the
550 * atomic transfer the reservation rows are gone and output_tokens_reserved
551 * is decremented.
552 */
553 static void test_transfer_atomic_quota_cleanup(void)
554 {
555 char db_path[64];
556 make_temp_db(db_path, sizeof(db_path));
557
558 Conversation_Store *p_store = Conversation_Store_Create(db_path);
559 assert(p_store);
560
561 /* Seed guest identity, usage, and reservation rows via a raw connection
562 * to simulate what the auth store would have written (in production both
563 * stores share the same SQLite file). */
564 Deita_Connection *p_conn =
565 Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, db_path);
566 assert(p_conn);
567 Deita_Query_Execute_Update(p_conn,
568 "PRAGMA foreign_keys = OFF;"
569 "PRAGMA journal_mode = WAL;");
570
571 /* Create auth tables that share the same file in production. */
572 Deita_Query_Execute_Update(p_conn,
573 "CREATE TABLE IF NOT EXISTS guest_identities ("
574 " id TEXT PRIMARY KEY,"
575 " ip_binding_digest TEXT NOT NULL,"
576 " created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
577 " last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
578 " expires_at INTEGER NOT NULL"
579 ");");
580 Deita_Query_Execute_Update(p_conn,
581 "CREATE TABLE IF NOT EXISTS guest_usage ("
582 " guest_id TEXT NOT NULL,"
583 " window_start INTEGER NOT NULL,"
584 " count INTEGER NOT NULL DEFAULT 0,"
585 " turns_used INTEGER NOT NULL DEFAULT 0,"
586 " output_tokens_used INTEGER NOT NULL DEFAULT 0,"
587 " output_tokens_reserved INTEGER NOT NULL DEFAULT 0,"
588 " PRIMARY KEY (guest_id, window_start)"
589 ");");
590 Deita_Query_Execute_Update(p_conn,
591 "CREATE TABLE IF NOT EXISTS guest_usage_reservations ("
592 " request_id TEXT PRIMARY KEY,"
593 " guest_id TEXT NOT NULL,"
594 " window_start INTEGER NOT NULL,"
595 " output_tokens_reserved INTEGER NOT NULL DEFAULT 0,"
596 " reserved_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
597 " expires_at INTEGER NOT NULL"
598 ");");
599
600 const char *gid = "aaaabbbb-cccc-4000-8000-111111111111";
601 const char *uid = "ddddeeee-ffff-4000-8000-222222222222";
602 const char *rid1 = "rrrr1111-0000-4000-8000-000000000001";
603 const char *rid2 = "rrrr2222-0000-4000-8000-000000000002";
604
605 /* guest_identities */
606 const char *gid_p[] = {gid};
607 Deita_Query_Execute_Update_Prepared(p_conn,
608 "INSERT OR IGNORE INTO guest_identities "
609 "(id, ip_binding_digest, expires_at) VALUES (?, 'x', 9999999999)",
610 1, gid_p);
611
612 /* guest_usage: 2 turns used, 300 tokens reserved */
613 const char *gu_p[] = {gid};
614 Deita_Query_Execute_Update_Prepared(p_conn,
615 "INSERT OR REPLACE INTO guest_usage "
616 "(guest_id, window_start, count, turns_used, output_tokens_used, "
617 " output_tokens_reserved) VALUES (?, 1700524800, 2, 2, 0, 300)",
618 1, gu_p);
619
620 /* Two reservation rows (100 + 200 = 300 total) */
621 const char *r1_p[] = {rid1, gid};
622 Deita_Query_Execute_Update_Prepared(p_conn,
623 "INSERT OR REPLACE INTO guest_usage_reservations "
624 "(request_id, guest_id, window_start, output_tokens_reserved, expires_at) "
625 "VALUES (?, ?, 1700524800, 100, 9999999999)",
626 2, r1_p);
627 const char *r2_p[] = {rid2, gid};
628 Deita_Query_Execute_Update_Prepared(p_conn,
629 "INSERT OR REPLACE INTO guest_usage_reservations "
630 "(request_id, guest_id, window_start, output_tokens_reserved, expires_at) "
631 "VALUES (?, ?, 1700524800, 200, 9999999999)",
632 2, r2_p);
633 Deita_Connection_Close(p_conn);
634
635 /* Create one guest conversation */
636 Conversation_Owner g_owner = {CONVERSATION_OWNER_KIND_GUEST, ""};
637 strncpy(g_owner.id, gid, 36);
638 char conv_id[37];
639 assert(Conversation_Store_Create_Owned(p_store, "G conv", &g_owner, conv_id)
640 == CONVERSATION_STORE_OK);
641
642 /* Atomic transfer */
643 assert(Conversation_Store_Transfer_Guest_To_User_Atomic(
644 p_store, gid, uid) == CONVERSATION_STORE_OK);
645
646 /* Verify: conversation now belongs to user */
647 Conversation_Owner u_owner = {CONVERSATION_OWNER_KIND_USER, ""};
648 strncpy(u_owner.id, uid, 36);
649 Dowa_Arena *p_arena = Dowa_Arena_Create(8 * 1024);
650 Conversation_Record rec;
651 assert(Conversation_Store_Get_Owned(
652 p_store, conv_id, &u_owner, &rec, p_arena) == CONVERSATION_STORE_OK);
653 Dowa_Arena_Free(p_arena);
654
655 /* Verify: quota reservation rows deleted and output_tokens_reserved = 0 */
656 Dowa_Arena *chk_arena = Dowa_Arena_Create(4096);
657 Deita_Connection *p_chk =
658 Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, db_path);
659 assert(p_chk);
660 Deita_Query_Execute_Update(p_chk, "PRAGMA journal_mode = WAL;");
661
662 const char *chk_gid[] = {gid};
663 Deita_Result_Set *rs = Deita_Query_Execute_Prepared(
664 p_chk,
665 "SELECT output_tokens_reserved FROM guest_usage WHERE guest_id = ?",
666 1, chk_gid, chk_arena);
667 assert(rs && Deita_Result_Set_Next(rs));
668 assert(Deita_Result_Set_Get_Integer(rs, 0) == 0);
669 Deita_Result_Set_Free(rs);
670
671 rs = Deita_Query_Execute_Prepared(
672 p_chk,
673 "SELECT COUNT(*) FROM guest_usage_reservations WHERE guest_id = ?",
674 1, chk_gid, chk_arena);
675 assert(rs && Deita_Result_Set_Next(rs));
676 assert(Deita_Result_Set_Get_Integer(rs, 0) == 0);
677 Deita_Result_Set_Free(rs);
678
679 Deita_Connection_Close(p_chk);
680 Dowa_Arena_Free(chk_arena);
681
682 /* Idempotent: re-run same transfer is a no-op */
683 assert(Conversation_Store_Transfer_Guest_To_User_Atomic(
684 p_store, gid, uid) == CONVERSATION_STORE_OK);
685
686 Conversation_Store_Destroy(p_store);
687 unlink(db_path);
688 printf(" transfer_atomic_quota_cleanup PASS\n");
689 }
690
691 int main(void)
692 {
693 printf("conversation_store tests:\n");
694
695 char legacy_db[64];
696 make_temp_db(legacy_db, sizeof(legacy_db));
697 test_legacy_api(legacy_db);
698 unlink(legacy_db);
699
700 test_migration_from_old_schema();
701 test_owned_crud_and_isolation();
702 test_list_and_cursor();
703 test_claim_legacy();
704 test_guest_transfer();
705 test_transfer_stale_guest_create();
706 test_transfer_conflict();
707 test_index_desc_migration();
708 test_transfer_atomic_quota_cleanup();
709
710 printf("All conversation store tests passed\n");
122 return 0; 711 return 0;
123 } 712 }