// tests/test_sessions.el — unit tests for sessions.el // // Tests cover: // 1. Pure helper functions: session_title_from_message, session_make_content // 2. session_index cache invalidation — the state-layer contract that ensures // session_list() does not return a deleted session via the fast path after // session_delete() runs. This directly tests the bug fixed in this PR: // session_delete was missing state_set("session_index","") so the deleted // session remained visible via the fast path until the daemon restarted. // 3. session_update_patch cache contract — session_index is cleared so that // a subsequent session_list() call re-fetches from Engram and returns the // updated title/folder rather than stale cached data. // 4. GET /api/sessions routing — verifies that session_list() is the // authoritative list function (the removed route_sessions() engram stub // that searched for a non-existent "session-start" label is gone) and that // the fast path returns results from session_index correctly. import "../sessions.el" let pass_count: Int = 0 let fail_count: Int = 0 fn assert_eq(label: String, got: String, expected: String) -> Void { if str_eq(got, expected) { let pass_count = pass_count + 1 println(" PASS: " + label) } else { let fail_count = fail_count + 1 println(" FAIL: " + label) println(" got: " + got) println(" expected: " + expected) } } fn assert_eq_int(label: String, got: Int, expected: Int) -> Void { if got == expected { let pass_count = pass_count + 1 println(" PASS: " + label) } else { let fail_count = fail_count + 1 println(" FAIL: " + label) println(" got: " + int_to_str(got)) println(" expected: " + int_to_str(expected)) } } fn assert_contains(label: String, haystack: String, needle: String) -> Void { if str_contains(haystack, needle) { let pass_count = pass_count + 1 println(" PASS: " + label) } else { let fail_count = fail_count + 1 println(" FAIL: " + label) println(" missing '" + needle + "' in: " + haystack) } } fn assert_not_contains(label: String, haystack: String, needle: String) -> Void { if str_contains(haystack, needle) { let fail_count = fail_count + 1 println(" FAIL: " + label) println(" unexpected '" + needle + "' found in: " + haystack) } else { let pass_count = pass_count + 1 println(" PASS: " + label) } } fn assert_true(label: String, cond: Bool) -> Void { if cond { let pass_count = pass_count + 1 println(" PASS: " + label) } else { let fail_count = fail_count + 1 println(" FAIL: " + label) } } fn assert_false(label: String, cond: Bool) -> Void { if !cond { let pass_count = pass_count + 1 println(" PASS: " + label) } else { let fail_count = fail_count + 1 println(" FAIL: " + label) } } // ───────────────────────────────────────────────────────────────────────────── // 1. session_title_from_message // ───────────────────────────────────────────────────────────────────────────── println("") println("1. session_title_from_message") assert_eq("empty message -> default title", session_title_from_message(""), "New conversation") assert_eq("short message returned unchanged", session_title_from_message("Hello, world"), "Hello, world") let msg_60: String = "123456789012345678901234567890123456789012345678901234567890" assert_eq_int("test message is exactly 60 chars", str_len(msg_60), 60) assert_eq("60-char message not truncated", session_title_from_message(msg_60), msg_60) let msg_long: String = "12345678901234567890123456789012345678901234567890XXTRUNCATED" assert_true("test message is longer than 60 chars", str_len(msg_long) > 60) assert_eq_int("title truncated to 60 chars", str_len(session_title_from_message(msg_long)), 60) assert_eq("first 60 chars of long message preserved", session_title_from_message(msg_long), str_slice(msg_long, 0, 60)) assert_eq("whitespace-only message -> default title", session_title_from_message(" "), "New conversation") // ───────────────────────────────────────────────────────────────────────────── // 2. session_make_content // ───────────────────────────────────────────────────────────────────────────── println("") println("2. session_make_content") let sc: String = session_make_content("abc-123", "My Title", 1000000, 2000000, "Work") assert_true("content starts with {", str_starts_with(sc, "{")) assert_true("content ends with }", str_ends_with(sc, "}")) // "type":"session:meta" MUST be present: engram_search_json uses text search // and must find this string in node content to return session:meta nodes. // Removing it breaks the session_list() slow path (cross-restart recovery). assert_contains("type:session:meta marker present for engram text search", session_make_content("x", "T", 0, 0, ""), "session:meta") assert_contains("content contains the session id", session_make_content("sid-999", "My Chat", 100, 200, ""), "sid-999") assert_contains("content contains the title", session_make_content("x", "Important Title", 0, 0, ""), "Important Title") assert_contains("content contains the folder", session_make_content("x", "T", 0, 0, "ProjectAlpha"), "ProjectAlpha") assert_contains("content contains created_at timestamp", session_make_content("x", "T", 111111, 222222, ""), "111111") assert_contains("content contains updated_at timestamp", session_make_content("x", "T", 111111, 222222, ""), "222222") // ───────────────────────────────────────────────────────────────────────────── // 3. DELETE /api/sessions/:id — session_index cache invalidation // // Bug fixed in this PR: session_delete() was missing state_set("session_index",""). // Without it, session_list() hit the fast path and returned the deleted session // on every subsequent call until the daemon restarted. // // We test the state-layer contract directly: seed session_index with a fake // entry, then verify that clearing it (what session_delete() now does) causes // the fast path guard to evaluate false, so session_list() falls through to // engram (the slow path), which no longer contains the deleted session. // ───────────────────────────────────────────────────────────────────────────── println("") println("3. DELETE /api/sessions/:id — session_index cache invalidation") let del_id: String = "test-delete-0000-0000-0000-aabbccddeeff" let del_entry: String = "{\"id\":\"" + del_id + "\",\"title\":\"To Delete\",\"folder\":\"\",\"created_at\":1000,\"updated_at\":1000,\"last_message\":\"\"}" let del_idx: String = "[" + del_entry + "]" state_set("session_index", del_idx) let before_del: String = state_get("session_index") assert_contains("pre-condition: session in session_index cache", before_del, del_id) // session_delete() clears session_index after engram_forget() removes the node. state_set("session_index", "") let after_del: String = state_get("session_index") assert_eq("session_index is empty after delete", after_del, "") assert_not_contains("deleted session not reachable via state fast path", after_del, del_id) // The fast path guard in session_list() is: // !str_eq(state_idx, "") && !str_eq(state_idx, "[]") let fast_path_after_delete: Bool = !str_eq(after_del, "") && !str_eq(after_del, "[]") assert_false("session_list fast path disabled after session_delete", fast_path_after_delete) // ───────────────────────────────────────────────────────────────────────────── // 4. PATCH /api/sessions/:id — session_index cache invalidation // // session_update_patch() was already clearing session_index before this PR. // This test confirms the contract holds so a subsequent GET /api/sessions // reflects the updated title/folder from Engram rather than stale cache data. // ───────────────────────────────────────────────────────────────────────────── println("") println("4. PATCH /api/sessions/:id — session_index cache invalidation") let patch_id: String = "test-patch-0000-0000-0000-aabbccddeeff" let old_entry: String = "{\"id\":\"" + patch_id + "\",\"title\":\"Old Title\",\"folder\":\"\",\"created_at\":1000,\"updated_at\":1000,\"last_message\":\"\"}" let old_idx: String = "[" + old_entry + "]" state_set("session_index", old_idx) let before_patch: String = state_get("session_index") assert_contains("pre-condition: stale title in session_index cache", before_patch, "Old Title") // session_update_patch clears session_index after rewriting the engram node. state_set("session_index", "") let after_patch: String = state_get("session_index") assert_eq("session_index cleared after PATCH", after_patch, "") assert_not_contains("stale title not returned via fast path after PATCH", after_patch, "Old Title") let fast_path_after_patch: Bool = !str_eq(after_patch, "") && !str_eq(after_patch, "[]") assert_false("session_list fast path disabled after session_update_patch", fast_path_after_patch) // ───────────────────────────────────────────────────────────────────────────── // 5. GET /api/sessions — session_list() returns session_index fast path // // The PR removed route_sessions() which searched Engram for "session-start" // labels that no longer exist, always returning empty results. // GET /api/sessions is now wired to session_list() instead. // // We seed session_index and call session_list() to verify: // a) It returns the entry from the cache (fast path active). // b) It does not include any "session-start" label artifact. // ───────────────────────────────────────────────────────────────────────────── println("") println("5. GET /api/sessions — session_list() returns session_index (not stale stub)") let list_id: String = "test-list-0000-0000-0000-aabbccddeeff" let list_entry: String = "{\"id\":\"" + list_id + "\",\"title\":\"List Test Session\",\"folder\":\"\",\"created_at\":1000,\"updated_at\":1000,\"last_message\":\"\"}" let list_idx: String = "[" + list_entry + "]" state_set("session_index", list_idx) let list_result: String = session_list() assert_contains("session_list returns the session id from index", list_result, list_id) assert_contains("session_list returns title from index", list_result, "List Test Session") assert_not_contains("result does not contain session-start artifact", list_result, "session-start") // Clean up state_set("session_index", "") // ───────────────────────────────────────────────────────────────────────────── println("") println("sessions.el tests: " + int_to_str(pass_count) + " passed, " + int_to_str(fail_count) + " failed")