import { beforeEach, describe, expect, mock, test } from 'bun:test'; interface NotePayload { id: string; body: string; createdAt: number; updatedAt: number; source: 'manual' | 'selection' | 'agent'; pinned: boolean; } interface ContextPayload { notes: NotePayload[]; todos: { id: string; text: string; completed: boolean; createdAt: number }[]; plans: { id: string; file: string; title: string; createdAt: number; pinned: boolean }[]; } const emptyPayload = (): ContextPayload => ({ notes: [], todos: [], plans: [] }); const note = (overrides: Partial = {}): NotePayload => ({ id: 'n1', body: 'body', createdAt: 1, updatedAt: 1, source: 'manual', pinned: false, ...overrides, }); const planLink = (overrides: Partial = {}) => ({ id: 'p1', file: 'a.md', title: 'A', createdAt: 1, pinned: false, ...overrides, }); // The UI tsconfig does not load bun's test globals, so these tests follow the // local precedent of swapping plain handlers instead of using mock helpers. const handlers = { fetch: async (): Promise => emptyPayload(), saveTodos: async (todos: ContextPayload['todos']): Promise => ({ notes: [], todos, plans: [], }), createNote: async (): Promise<{ note: NotePayload; context: ContextPayload }> => ({ note: note(), context: { notes: [note()], todos: [], plans: [] }, }), updateNote: async (): Promise => note(), deleteNote: async (): Promise => emptyPayload(), create: async (): Promise<{ plan: ContextPayload['plans'][number]; context: ContextPayload }> => ({ plan: planLink(), context: { notes: [], todos: [], plans: [planLink()] }, }), update: async (): Promise<{ plan: ContextPayload['plans'][number]; raw: string } | null> => ({ plan: planLink(), raw: '# A', }), pinPlan: async (): Promise => planLink({ pinned: true }), remove: async (): Promise => emptyPayload(), }; const calls = { fetch: 0, saveTodos: 0, createNote: 0, updateNote: 0, deleteNote: 0, create: 0, update: 0, pinPlan: 0, remove: 0 }; mock.module('@/lib/projectContextApi', () => ({ fetchProjectContext: () => { calls.fetch += 1; return handlers.fetch(); }, saveProjectTodos: (_project: unknown, todos: ContextPayload['todos']) => { calls.saveTodos += 1; return handlers.saveTodos(todos); }, createProjectNote: () => { calls.createNote += 1; return handlers.createNote(); }, updateProjectNote: () => { calls.updateNote += 1; return handlers.updateNote(); }, deleteProjectNote: () => { calls.deleteNote += 1; return handlers.deleteNote(); }, setProjectPlanPinned: () => { calls.pinPlan += 1; return handlers.pinPlan(); }, createProjectPlan: () => { calls.create += 1; return handlers.create(); }, updateProjectPlan: () => { calls.update += 1; return handlers.update(); }, deleteProjectPlan: () => { calls.remove += 1; return handlers.remove(); }, resolveProjectContextId: (project: { path?: string } | null | undefined) => ( project?.path ? `path_${project.path}` : '' ), })); const { useProjectContextStore } = await import('./useProjectContextStore'); const PROJECT = { id: 'ignored', path: '/repo' }; const store = () => useProjectContextStore.getState(); const entry = () => store().getEntry(PROJECT); const deferred = () => { let resolve!: (value: T) => void; const promise = new Promise((res) => { resolve = res; }); return { promise, resolve }; }; const failWith = (message: string) => async (): Promise => { throw new Error(message); }; beforeEach(() => { store().reset(); calls.fetch = 0; calls.saveTodos = 0; calls.createNote = 0; calls.updateNote = 0; calls.deleteNote = 0; calls.create = 0; calls.update = 0; calls.pinPlan = 0; calls.remove = 0; handlers.fetch = async () => emptyPayload(); handlers.saveTodos = async (todos) => ({ notes: [], todos, plans: [] }); handlers.createNote = async () => ({ note: note(), context: { notes: [note()], todos: [], plans: [] } }); handlers.updateNote = async () => note(); handlers.deleteNote = async () => emptyPayload(); handlers.create = async () => ({ plan: planLink(), context: { notes: [], todos: [], plans: [planLink()] } }); handlers.update = async () => ({ plan: planLink(), raw: '# A' }); handlers.pinPlan = async () => planLink({ pinned: true }); handlers.remove = async () => emptyPayload(); }); describe('getEntry', () => { test('returns a stable empty entry for an unknown project', () => { expect(entry()).toEqual({ notes: [], todos: [], plans: [], loaded: false, loading: false, error: null }); }); test('returns the empty entry for a project without a path', () => { expect(store().getEntry({ id: 'x', path: '' }).loaded).toBe(false); }); }); describe('load', () => { test('populates from the server', async () => { handlers.fetch = async () => ({ notes: [note({ body: 'server note' })], todos: [{ id: 't1', text: 'a', completed: false, createdAt: 1 }], plans: [planLink()], }); await store().load(PROJECT); expect(entry().notes.map((entryNote) => entryNote.body)).toEqual(['server note']); expect(entry().todos).toHaveLength(1); expect(entry().plans).toHaveLength(1); expect(entry().loaded).toBe(true); expect(entry().error).toBeNull(); }); test('does not refetch once loaded', async () => { await store().load(PROJECT); await store().load(PROJECT); expect(calls.fetch).toBe(1); }); test('refetches when forced', async () => { await store().load(PROJECT); await store().load(PROJECT, { force: true }); expect(calls.fetch).toBe(2); }); test('a failed load preserves previously loaded data instead of clearing it', async () => { handlers.fetch = async () => ({ notes: [note({ body: 'kept' })], todos: [], plans: [] }); await store().load(PROJECT); handlers.fetch = failWith('offline'); await store().load(PROJECT, { force: true }); expect(entry().notes.map((entryNote) => entryNote.body)).toEqual(['kept']); expect(entry().loaded).toBe(true); expect(entry().error).toBe('offline'); }); test('a first-load failure reports the error and stays unloaded', async () => { handlers.fetch = failWith('boom'); await store().load(PROJECT); expect(entry().loaded).toBe(false); expect(entry().notes).toEqual([]); expect(entry().error).toBe('boom'); }); test('concurrent loads issue a single request', async () => { await Promise.all([store().load(PROJECT), store().load(PROJECT), store().load(PROJECT)]); expect(calls.fetch).toBe(1); }); }); describe('saveTodos', () => { test('applies optimistically before the request resolves', async () => { const gate = deferred(); handlers.saveTodos = () => gate.promise; const pending = store().saveTodos(PROJECT, [{ id: 't1', text: 'typed', completed: false, createdAt: 1 }]); expect(entry().todos).toHaveLength(1); gate.resolve({ notes: [], todos: [{ id: 't1', text: 'typed', completed: false, createdAt: 1 }], plans: [] }); expect(await pending).toBe(true); expect(entry().todos).toHaveLength(1); }); test('rolls back and reports the error on failure', async () => { await store().saveTodos(PROJECT, [{ id: 't1', text: 'original', completed: false, createdAt: 1 }]); handlers.saveTodos = failWith('disk full'); expect(await store().saveTodos(PROJECT, [])).toBe(false); expect(entry().todos.map((todo) => todo.text)).toEqual(['original']); expect(entry().error).toBe('disk full'); }); test('serializes concurrent writes in call order', async () => { const order: string[] = []; handlers.saveTodos = async (todos) => { const label = todos[0]?.text ?? 'empty'; order.push(`start:${label}`); await new Promise((resolve) => setTimeout(resolve, 5)); order.push(`end:${label}`); return { notes: [], todos, plans: [] }; }; await Promise.all([ store().saveTodos(PROJECT, [{ id: '1', text: 'first', completed: false, createdAt: 1 }]), store().saveTodos(PROJECT, [{ id: '2', text: 'second', completed: false, createdAt: 2 }]), ]); expect(order).toEqual(['start:first', 'end:first', 'start:second', 'end:second']); }); test('a load resolving during an in-flight write does not clobber it', async () => { const gate = deferred(); handlers.saveTodos = () => gate.promise; handlers.fetch = async () => ({ notes: [note({ body: 'from server' })], todos: [{ id: 'stale', text: 'stale', completed: false, createdAt: 0 }], plans: [], }); const pending = store().saveTodos(PROJECT, [{ id: 'local', text: 'local', completed: false, createdAt: 1 }]); await store().load(PROJECT); expect(entry().todos.map((todo) => todo.id)).toEqual(['local']); // The same snapshot still delivers the fields the write did not touch. expect(entry().notes.map((entryNote) => entryNote.body)).toEqual(['from server']); gate.resolve({ notes: [], todos: [{ id: 'local', text: 'local', completed: false, createdAt: 1 }], plans: [] }); await pending; }); test('ignores a project without a resolvable path', async () => { expect(await store().saveTodos({ id: 'x', path: '' }, [])).toBe(false); expect(calls.saveTodos).toBe(0); }); }); describe('notes', () => { test('createNote adopts the committed list', async () => { handlers.createNote = async () => ({ note: note({ id: 'n9', body: 'fresh' }), context: { notes: [note({ id: 'n9', body: 'fresh' })], todos: [], plans: [] }, }); const created = await store().createNote(PROJECT, { body: 'fresh' }); expect(created?.id).toBe('n9'); expect(entry().notes.map((entryNote) => entryNote.id)).toEqual(['n9']); }); test('createNote refuses a whitespace-only body without calling the server', async () => { expect(await store().createNote(PROJECT, { body: ' ' })).toBeNull(); expect(calls.createNote).toBe(0); }); test('createNote reports failure without inserting a placeholder row', async () => { handlers.createNote = failWith('no space'); expect(await store().createNote(PROJECT, { body: 'x' })).toBeNull(); expect(entry().notes).toEqual([]); expect(entry().error).toBe('no space'); }); test('saveNoteBody applies optimistically and commits the server copy', async () => { await store().createNote(PROJECT, { body: 'before' }); handlers.updateNote = async () => note({ body: 'after', updatedAt: 9 }); expect(await store().saveNoteBody(PROJECT, 'n1', 'after')).toBe(true); expect(entry().notes[0].body).toBe('after'); expect(entry().notes[0].updatedAt).toBe(9); }); test('saveNoteBody rolls back on failure', async () => { await store().createNote(PROJECT, { body: 'before' }); handlers.updateNote = failWith('read only'); expect(await store().saveNoteBody(PROJECT, 'n1', 'after')).toBe(false); expect(entry().notes[0].body).toBe('body'); expect(entry().error).toBe('read only'); }); test('saveNoteBody drops a note the server reports as gone', async () => { await store().createNote(PROJECT, { body: 'before' }); handlers.updateNote = async () => null; expect(await store().saveNoteBody(PROJECT, 'n1', 'after')).toBe(false); expect(entry().notes).toEqual([]); }); test('setNotePinned applies optimistically', async () => { await store().createNote(PROJECT, { body: 'x' }); const gate = deferred(); handlers.updateNote = () => gate.promise; const pending = store().setNotePinned(PROJECT, 'n1', true); expect(entry().notes[0].pinned).toBe(true); gate.resolve(note({ pinned: true })); expect(await pending).toBe(true); }); test('setNotePinned rolls back on failure', async () => { await store().createNote(PROJECT, { body: 'x' }); handlers.updateNote = failWith('locked'); expect(await store().setNotePinned(PROJECT, 'n1', true)).toBe(false); expect(entry().notes[0].pinned).toBe(false); }); test('deleteNote removes optimistically and restores on failure', async () => { await store().createNote(PROJECT, { body: 'x' }); handlers.deleteNote = failWith('busy'); expect(await store().deleteNote(PROJECT, 'n1')).toBe(false); expect(entry().notes.map((entryNote) => entryNote.id)).toEqual(['n1']); expect(entry().error).toBe('busy'); }); test('deleteNote commits the server list on success', async () => { await store().createNote(PROJECT, { body: 'x' }); expect(await store().deleteNote(PROJECT, 'n1')).toBe(true); expect(entry().notes).toEqual([]); }); }); describe('plans', () => { test('createPlan commits the server context', async () => { const plan = await store().createPlan(PROJECT, { title: 'A', body: 'x' }); expect(plan?.id).toBe('p1'); expect(entry().plans.map((item) => item.id)).toEqual(['p1']); }); test('createPlan reports failure without inserting a placeholder row', async () => { handlers.create = failWith('no space'); expect(await store().createPlan(PROJECT, { title: 'A', body: 'x' })).toBeNull(); expect(entry().plans).toEqual([]); expect(entry().error).toBe('no space'); }); test('deletePlan removes optimistically', async () => { await store().createPlan(PROJECT, { title: 'A', body: 'x' }); const gate = deferred(); handlers.remove = () => gate.promise; const pending = store().deletePlan(PROJECT, 'p1'); expect(entry().plans).toEqual([]); gate.resolve(emptyPayload()); expect(await pending).toBe(true); }); test('savePlan folds the refreshed title back into the list', async () => { await store().createPlan(PROJECT, { title: 'A', body: 'x' }); handlers.update = async () => ({ plan: planLink({ title: 'Renamed' }), raw: '# Renamed' }); expect(await store().savePlan(PROJECT, 'p1', '# Renamed')).toBe(true); expect(entry().plans[0].title).toBe('Renamed'); }); test('savePlan drops a plan the server reports as gone', async () => { await store().createPlan(PROJECT, { title: 'A', body: 'x' }); handlers.update = async () => null; expect(await store().savePlan(PROJECT, 'p1', '# X')).toBe(false); expect(entry().plans).toEqual([]); }); test('savePlan keeps the row and reports the error when the request fails', async () => { await store().createPlan(PROJECT, { title: 'A', body: 'x' }); handlers.update = failWith('read only'); expect(await store().savePlan(PROJECT, 'p1', '# X')).toBe(false); expect(entry().plans.map((item) => item.id)).toEqual(['p1']); expect(entry().error).toBe('read only'); }); test('setPlanPinned applies optimistically and rolls back on failure', async () => { await store().createPlan(PROJECT, { title: 'A', body: 'x' }); handlers.pinPlan = failWith('locked'); expect(await store().setPlanPinned(PROJECT, 'p1', true)).toBe(false); expect(entry().plans[0].pinned).toBe(false); expect(entry().error).toBe('locked'); }); test('setPlanPinned commits the server copy', async () => { await store().createPlan(PROJECT, { title: 'A', body: 'x' }); expect(await store().setPlanPinned(PROJECT, 'p1', true)).toBe(true); expect(entry().plans[0].pinned).toBe(true); }); test('deletePlan restores the row when the request fails', async () => { await store().createPlan(PROJECT, { title: 'A', body: 'x' }); handlers.remove = failWith('locked'); expect(await store().deletePlan(PROJECT, 'p1')).toBe(false); expect(entry().plans.map((item) => item.id)).toEqual(['p1']); expect(entry().error).toBe('locked'); }); }); describe('reset', () => { test('drops every cached project', async () => { await store().load(PROJECT); expect(entry().loaded).toBe(true); store().reset(); expect(entry().loaded).toBe(false); }); });