session.test.ts (16287B)
1 import { describe, it, expect, beforeEach, vi } from "vitest"; 2 import { useSessionStore } from "./session"; 3 4 beforeEach(() => { 5 useSessionStore.setState({ 6 sessions: [], 7 activeSessionId: null, 8 loading: false, 9 error: null, 10 _allSessions: [], 11 _topicFilter: null, 12 }); 13 vi.restoreAllMocks(); 14 }); 15 16 describe("session store", () => { 17 it("has initial empty state", () => { 18 const state = useSessionStore.getState(); 19 expect(state.sessions).toEqual([]); 20 expect(state.activeSessionId).toBeNull(); 21 expect(state.loading).toBe(false); 22 expect(state.error).toBeNull(); 23 }); 24 25 it("listSessions fetches and populates sessions", async () => { 26 vi.stubGlobal( 27 "fetch", 28 vi.fn().mockResolvedValue({ 29 ok: true, 30 json: async () => ({ 31 sessions: [ 32 { id: "s1", name: "Chat 1", created_at: 1000, message_count: 1 }, 33 { id: "s2", name: "Chat 2", created_at: 2000, message_count: 1 }, 34 ], 35 }), 36 }), 37 ); 38 39 await useSessionStore.getState().listSessions(); 40 41 const { sessions, loading } = useSessionStore.getState(); 42 expect(loading).toBe(false); 43 expect(sessions).toHaveLength(2); 44 expect(sessions[0].id).toBe("s2"); // sorted newest first 45 }); 46 47 it("listSessions sets loading to true during fetch", async () => { 48 let resolveFetch: (v: unknown) => void; 49 vi.stubGlobal("fetch", vi.fn().mockReturnValue( 50 new Promise((resolve) => { resolveFetch = resolve; }), 51 )); 52 53 const promise = useSessionStore.getState().listSessions(); 54 expect(useSessionStore.getState().loading).toBe(true); 55 56 resolveFetch!({ ok: true, json: async () => ({ sessions: [] }) }); 57 await promise; 58 expect(useSessionStore.getState().loading).toBe(false); 59 }); 60 61 it("createSession adds a new session", async () => { 62 vi.stubGlobal( 63 "fetch", 64 vi.fn().mockResolvedValue({ 65 ok: true, 66 json: async () => ({ id: "s3", name: "New Chat" }), 67 }), 68 ); 69 70 await useSessionStore.getState().createSession("New Chat"); 71 72 const { _allSessions, sessions } = useSessionStore.getState(); 73 // Session is in _allSessions but filtered from sessions (0 messages) 74 expect(_allSessions).toHaveLength(1); 75 expect(_allSessions[0].id).toBe("s3"); 76 expect(_allSessions[0].name).toBe("New Chat"); 77 expect(sessions).toHaveLength(0); 78 }); 79 80 it("createSession with topic_id sends topic in body", async () => { 81 const mockFetch = vi.fn().mockResolvedValue({ 82 ok: true, 83 json: async () => ({ id: "s4", name: "Work Chat" }), 84 }); 85 vi.stubGlobal("fetch", mockFetch); 86 87 await useSessionStore.getState().createSession("Work Chat", "t1"); 88 89 const body = JSON.parse(mockFetch.mock.calls[0][1].body); 90 expect(body.name).toBe("Work Chat"); 91 expect(body.topic_id).toBe("t1"); 92 }); 93 94 it("createSession without name sends empty body", async () => { 95 const mockFetch = vi.fn().mockResolvedValue({ 96 ok: true, 97 json: async () => ({ id: "s5" }), 98 }); 99 vi.stubGlobal("fetch", mockFetch); 100 101 await useSessionStore.getState().createSession(); 102 103 const body = JSON.parse(mockFetch.mock.calls[0][1].body); 104 expect(body).toEqual({}); 105 }); 106 107 it("createSession sets error on failure", async () => { 108 vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("create failed"))); 109 110 await useSessionStore.getState().createSession("Test"); 111 112 const { error } = useSessionStore.getState(); 113 expect(error).toBe("create failed"); 114 }); 115 116 it("createSession sets generic error for non-Error rejection", async () => { 117 vi.stubGlobal("fetch", vi.fn().mockRejectedValue("string error")); 118 119 await useSessionStore.getState().createSession("Test"); 120 expect(useSessionStore.getState().error).toBe("Failed to create session"); 121 }); 122 123 it("updateSession renames a session", async () => { 124 useSessionStore.setState({ 125 sessions: [{ id: "s1", name: "Old Name", created_at: 1000, message_count: 1 }], 126 }); 127 128 vi.stubGlobal( 129 "fetch", 130 vi.fn().mockResolvedValue({ 131 ok: true, 132 json: async () => ({ id: "s1", name: "New Name" }), 133 }), 134 ); 135 136 await useSessionStore.getState().updateSession("s1", { name: "New Name" }); 137 138 const { sessions } = useSessionStore.getState(); 139 expect(sessions[0].name).toBe("New Name"); 140 }); 141 142 it("updateSession sets error on failure", async () => { 143 useSessionStore.setState({ 144 sessions: [{ id: "s1", name: "Old", created_at: 1000, message_count: 1 }], 145 }); 146 vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("update failed"))); 147 148 await useSessionStore.getState().updateSession("s1", { name: "New" }); 149 expect(useSessionStore.getState().error).toBe("update failed"); 150 }); 151 152 it("deleteSession removes from list", async () => { 153 const allSessions = [ 154 { id: "s1", name: "Chat 1", created_at: 1000, message_count: 1 }, 155 { id: "s2", name: "Chat 2", created_at: 2000, message_count: 1 }, 156 ]; 157 useSessionStore.setState({ 158 sessions: allSessions, 159 _allSessions: allSessions, 160 }); 161 162 vi.stubGlobal( 163 "fetch", 164 vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }), 165 ); 166 167 await useSessionStore.getState().deleteSession("s1"); 168 const { sessions } = useSessionStore.getState(); 169 expect(sessions).toHaveLength(1); 170 expect(sessions[0].id).toBe("s2"); 171 }); 172 173 it("deleteSession clears activeSessionId if active session deleted", async () => { 174 const allSessions = [ 175 { id: "s1", name: "Chat 1", created_at: 1000, message_count: 1 }, 176 { id: "s2", name: "Chat 2", created_at: 2000, message_count: 1 }, 177 ]; 178 useSessionStore.setState({ 179 sessions: allSessions, 180 _allSessions: allSessions, 181 activeSessionId: "s1", 182 }); 183 184 vi.stubGlobal( 185 "fetch", 186 vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }), 187 ); 188 189 await useSessionStore.getState().deleteSession("s1"); 190 const { activeSessionId } = useSessionStore.getState(); 191 expect(activeSessionId).toBeNull(); 192 }); 193 194 it("deleteSession keeps activeSessionId when deleting different session", async () => { 195 const allSessions = [ 196 { id: "s1", name: "Chat 1", created_at: 1000, message_count: 1 }, 197 { id: "s2", name: "Chat 2", created_at: 2000, message_count: 1 }, 198 ]; 199 useSessionStore.setState({ 200 sessions: allSessions, 201 _allSessions: allSessions, 202 activeSessionId: "s1", 203 }); 204 205 vi.stubGlobal( 206 "fetch", 207 vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }), 208 ); 209 210 await useSessionStore.getState().deleteSession("s2"); 211 expect(useSessionStore.getState().activeSessionId).toBe("s1"); 212 }); 213 214 it("deleteSession sets error on failure", async () => { 215 useSessionStore.setState({ 216 sessions: [{ id: "s1", name: "Chat", created_at: 1000, message_count: 1 }], 217 }); 218 vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("delete failed"))); 219 220 await useSessionStore.getState().deleteSession("s1"); 221 expect(useSessionStore.getState().error).toBe("delete failed"); 222 }); 223 224 it("setActiveSession updates activeSessionId", () => { 225 useSessionStore.getState().setActiveSession("s1"); 226 expect(useSessionStore.getState().activeSessionId).toBe("s1"); 227 }); 228 229 it("setActiveSession can set to null", () => { 230 useSessionStore.setState({ activeSessionId: "s1" }); 231 useSessionStore.getState().setActiveSession(null); 232 expect(useSessionStore.getState().activeSessionId).toBeNull(); 233 }); 234 235 it("filterByTopic filters sessions by topic_id", () => { 236 const allSessions = [ 237 { id: "s1", name: "Chat 1", topic_id: "t1", created_at: 1000, message_count: 1 }, 238 { id: "s2", name: "Chat 2", topic_id: "t2", created_at: 2000, message_count: 1 }, 239 { id: "s3", name: "Chat 3", topic_id: "t1", created_at: 3000, message_count: 1 }, 240 ]; 241 useSessionStore.setState({ 242 sessions: allSessions, 243 _allSessions: allSessions, 244 }); 245 246 useSessionStore.getState().filterByTopic("t1"); 247 248 const { sessions } = useSessionStore.getState(); 249 expect(sessions).toHaveLength(2); 250 expect(sessions.every((s) => s.topic_id === "t1")).toBe(true); 251 }); 252 253 it("filterByTopic with no matching topic returns empty", () => { 254 const allSessions = [ 255 { id: "s1", name: "Chat 1", topic_id: "t1", created_at: 1000, message_count: 1 }, 256 ]; 257 useSessionStore.setState({ 258 sessions: allSessions, 259 _allSessions: allSessions, 260 }); 261 262 useSessionStore.getState().filterByTopic("nonexistent"); 263 264 const { sessions } = useSessionStore.getState(); 265 expect(sessions).toHaveLength(0); 266 }); 267 268 it("filterByTopic with null shows all sessions", () => { 269 const allSessions = [ 270 { id: "s1", name: "Chat 1", topic_id: "t1", created_at: 1000, message_count: 1 }, 271 { id: "s2", name: "Chat 2", topic_id: "t2", created_at: 2000, message_count: 1 }, 272 ]; 273 useSessionStore.setState({ 274 sessions: allSessions, 275 _allSessions: allSessions, 276 }); 277 278 useSessionStore.getState().filterByTopic(null); 279 280 const { sessions } = useSessionStore.getState(); 281 expect(sessions).toHaveLength(2); 282 }); 283 284 it("clearFilter restores all sessions", () => { 285 const allSessions = [ 286 { id: "s1", name: "Chat 1", topic_id: "t1", created_at: 1000, message_count: 1 }, 287 { id: "s2", name: "Chat 2", topic_id: "t2", created_at: 2000, message_count: 1 }, 288 ]; 289 useSessionStore.setState({ 290 sessions: allSessions, 291 _allSessions: allSessions, 292 _topicFilter: "t1", 293 }); 294 295 useSessionStore.getState().clearFilter(); 296 297 const state = useSessionStore.getState(); 298 expect(state.sessions).toHaveLength(2); 299 expect(state._topicFilter).toBeNull(); 300 }); 301 302 it("listSessions sets error on failure", async () => { 303 vi.stubGlobal( 304 "fetch", 305 vi.fn().mockResolvedValue({ 306 ok: false, 307 json: async () => ({ error: "Server error" }), 308 }), 309 ); 310 311 await useSessionStore.getState().listSessions(); 312 313 const { error, loading } = useSessionStore.getState(); 314 expect(error).toBeTruthy(); 315 expect(loading).toBe(false); 316 }); 317 318 it("listSessions handles non-Error rejection", async () => { 319 vi.stubGlobal("fetch", vi.fn().mockRejectedValue("string error")); 320 321 await useSessionStore.getState().listSessions(); 322 expect(useSessionStore.getState().error).toBe("Failed to load sessions"); 323 expect(useSessionStore.getState().loading).toBe(false); 324 }); 325 326 it("createSession clears previous error", async () => { 327 useSessionStore.setState({ error: "old error" }); 328 vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ 329 ok: true, 330 json: async () => ({ id: "s1" }), 331 })); 332 333 await useSessionStore.getState().createSession("Test"); 334 expect(useSessionStore.getState().error).toBeNull(); 335 }); 336 337 it("deleteSession clears previous error", async () => { 338 useSessionStore.setState({ 339 sessions: [{ id: "s1", name: "Chat", created_at: 1000, message_count: 1 }], 340 error: "old error", 341 }); 342 vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ 343 ok: true, json: async () => ({}), 344 })); 345 346 await useSessionStore.getState().deleteSession("s1"); 347 expect(useSessionStore.getState().error).toBeNull(); 348 }); 349 350 it("archiveSession calls API and updates state", async () => { 351 const allSessions = [ 352 { id: "s1", name: "Chat 1", created_at: 1000, message_count: 1 }, 353 { id: "s2", name: "Chat 2", created_at: 2000, message_count: 1 }, 354 ]; 355 useSessionStore.setState({ 356 sessions: allSessions, 357 _allSessions: allSessions, 358 }); 359 360 vi.stubGlobal( 361 "fetch", 362 vi.fn().mockResolvedValue({ 363 ok: true, 364 json: async () => ({ session_id: "s1", archived: true }), 365 }), 366 ); 367 368 await useSessionStore.getState().archiveSession("s1"); 369 const { sessions, _allSessions, activeSessionId } = useSessionStore.getState(); 370 expect(sessions).toHaveLength(1); 371 expect(sessions[0].id).toBe("s2"); 372 expect(_allSessions.find((s) => s.id === "s1")?.archived).toBe(true); 373 expect(activeSessionId).toBeNull(); 374 }); 375 376 it("archiveSession clears activeSessionId if active", async () => { 377 useSessionStore.setState({ 378 sessions: [{ id: "s1", name: "Chat", created_at: 1000, message_count: 1 }], 379 _allSessions: [{ id: "s1", name: "Chat", created_at: 1000, message_count: 1 }], 380 activeSessionId: "s1", 381 }); 382 383 vi.stubGlobal( 384 "fetch", 385 vi.fn().mockResolvedValue({ 386 ok: true, 387 json: async () => ({ session_id: "s1", archived: true }), 388 }), 389 ); 390 391 await useSessionStore.getState().archiveSession("s1"); 392 expect(useSessionStore.getState().activeSessionId).toBeNull(); 393 }); 394 395 it("archiveSession sets error on failure", async () => { 396 useSessionStore.setState({ 397 sessions: [{ id: "s1", name: "Chat", created_at: 1000, message_count: 1 }], 398 _allSessions: [{ id: "s1", name: "Chat", created_at: 1000, message_count: 1 }], 399 }); 400 vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("archive failed"))); 401 402 await useSessionStore.getState().archiveSession("s1"); 403 expect(useSessionStore.getState().error).toBe("archive failed"); 404 }); 405 406 it("unarchiveSession calls API and updates state", async () => { 407 useSessionStore.setState({ 408 sessions: [], 409 _allSessions: [ 410 { id: "s1", name: "Chat 1", created_at: 1000, message_count: 1, archived: true }, 411 { id: "s2", name: "Chat 2", created_at: 2000, message_count: 1 }, 412 ], 413 }); 414 415 vi.stubGlobal( 416 "fetch", 417 vi.fn().mockResolvedValue({ 418 ok: true, 419 status: 204, 420 json: async () => ({}), 421 }), 422 ); 423 424 await useSessionStore.getState().unarchiveSession("s1"); 425 const { _allSessions, sessions } = useSessionStore.getState(); 426 expect(_allSessions.find((s) => s.id === "s1")?.archived).toBe(false); 427 // After unarchive + _applyFilter, s1 should appear in sessions 428 expect(sessions.find((s) => s.id === "s1")).toBeTruthy(); 429 }); 430 431 it("unarchiveSession sets error on failure", async () => { 432 useSessionStore.setState({ 433 _allSessions: [ 434 { id: "s1", name: "Chat", created_at: 1000, message_count: 1, archived: true }, 435 ], 436 }); 437 vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("unarchive failed"))); 438 439 await useSessionStore.getState().unarchiveSession("s1"); 440 expect(useSessionStore.getState().error).toBe("unarchive failed"); 441 }); 442 443 it("_applyFilter excludes archived sessions", () => { 444 const allSessions = [ 445 { id: "s1", name: "Chat 1", created_at: 1000, message_count: 1, archived: true }, 446 { id: "s2", name: "Chat 2", created_at: 2000, message_count: 1 }, 447 ]; 448 useSessionStore.setState({ 449 _allSessions: allSessions, 450 _topicFilter: null, 451 }); 452 453 useSessionStore.getState()._applyFilter(); 454 const { sessions } = useSessionStore.getState(); 455 expect(sessions).toHaveLength(1); 456 expect(sessions[0].id).toBe("s2"); 457 }); 458 459 it("listSessions uses archived flag from API response", async () => { 460 vi.stubGlobal( 461 "fetch", 462 vi.fn().mockResolvedValue({ 463 ok: true, 464 json: async () => ({ 465 sessions: [ 466 { id: "s1", name: "Chat 1", created_at: 1000, message_count: 1, archived: true }, 467 { id: "s2", name: "Chat 2", created_at: 2000, message_count: 1, archived: false }, 468 ], 469 }), 470 }), 471 ); 472 473 await useSessionStore.getState().listSessions(); 474 const { sessions, _allSessions } = useSessionStore.getState(); 475 // Archived sessions are filtered out of display list 476 expect(sessions).toHaveLength(1); 477 expect(sessions[0].id).toBe("s2"); 478 // But present in _allSessions 479 expect(_allSessions).toHaveLength(2); 480 expect(_allSessions.find((s) => s.id === "s1")?.archived).toBe(true); 481 }); 482 483 it("_applyFilter returns all when no filter", () => { 484 const allSessions = [ 485 { id: "s1", name: "Chat 1", topic_id: "t1", created_at: 1000, message_count: 1 }, 486 ]; 487 useSessionStore.setState({ 488 _allSessions: allSessions, 489 _topicFilter: null, 490 }); 491 492 useSessionStore.getState()._applyFilter(); 493 expect(useSessionStore.getState().sessions).toEqual(allSessions); 494 }); 495 });