// Seam tests for useCrudList — the state-machine composable that owns a // single DRF list endpoint's items + dialog state + list mutators. The // composable does not localize, does not own form state, does not fetch // reference data — those responsibilities stay in the view / dialog. // // Service functions are vi.fn()s; no axios / network involved. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { useCrudList } from '@/composables/useCrudList.js' function makeServices(overrides = {}) { return { list: vi.fn().mockResolvedValue([]), create: vi.fn(), update: vi.fn(), deleteFn: vi.fn(), ...overrides, } } function makeCrud(overrides = {}) { const services = makeServices(overrides.services || {}) const crud = useCrudList({ list: services.list, create: services.create, update: services.update, deleteFn: services.deleteFn, idKey: overrides.idKey || 'student_id', mapRow: overrides.mapRow, loadErrorKey: overrides.loadErrorKey, }) return { crud, services } } beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) // ─── initial state ─────────────────────────────────────────────────────── describe('initial state', () => { it('starts with empty items and all dialog flags false', () => { const { crud } = makeCrud() expect(crud.items.value).toEqual([]) expect(crud.isLoading.value).toBe(false) expect(crud.isError.value).toBe(false) expect(crud.errorMessage.value).toBe('') expect(crud.deleteError.value).toBe('') expect(crud.editingItem.value).toBeNull() expect(crud.editingDialogOpen.value).toBe(false) expect(crud.itemToDelete.value).toBeNull() expect(crud.deleteDialogOpen.value).toBe(false) }) }) // ─── fetch ─────────────────────────────────────────────────────────────── describe('fetch', () => { it('success — assigns items, flips isLoading during', async () => { const services = makeServices({ list: vi.fn().mockResolvedValue([{ student_id: 1, name: 'A' }]) }) const crud = useCrudList({ list: services.list, create: services.create, update: services.update, deleteFn: services.deleteFn, idKey: 'student_id', }) const p = crud.fetch() expect(crud.isLoading.value).toBe(true) await p expect(crud.isLoading.value).toBe(false) expect(crud.items.value).toEqual([{ student_id: 1, name: 'A' }]) expect(crud.isError.value).toBe(false) }) it('success — applies mapRow to each row', async () => { const services = makeServices({ list: vi.fn().mockResolvedValue([{ student_id: 1, name: 'alice' }]), }) const crud = useCrudList({ list: services.list, create: services.create, update: services.update, deleteFn: services.deleteFn, idKey: 'student_id', mapRow: (r) => ({ ...r, display: r.name.toUpperCase() }), }) await crud.fetch() expect(crud.items.value[0]).toEqual({ student_id: 1, name: 'alice', display: 'ALICE', }) }) it('error — sets isError + errorMessage, auto-clears after 3000ms', async () => { const services = makeServices({ list: vi.fn().mockRejectedValue(new Error('boom')) }) const crud = useCrudList({ list: services.list, create: services.create, update: services.update, deleteFn: services.deleteFn, idKey: 'student_id', }) await crud.fetch() expect(crud.isError.value).toBe(true) expect(crud.errorMessage.value).toBe('boom') vi.advanceTimersByTime(2999) expect(crud.isError.value).toBe(true) vi.advanceTimersByTime(1) expect(crud.isError.value).toBe(false) expect(crud.errorMessage.value).toBe('') }) it('error — prefers DRF detail over error.message', async () => { const err = new Error('generic') err.response = { data: { detail: 'specific DRF message' } } const services = makeServices({ list: vi.fn().mockRejectedValue(err) }) const crud = useCrudList({ list: services.list, create: services.create, update: services.update, deleteFn: services.deleteFn, idKey: 'student_id', }) await crud.fetch() expect(crud.errorMessage.value).toBe('specific DRF message') }) it('error — falls back to loadErrorKey when no message present', async () => { const services = makeServices({ list: vi.fn().mockRejectedValue(new Error('')) }) const crud = useCrudList({ list: services.list, create: services.create, update: services.update, deleteFn: services.deleteFn, idKey: 'student_id', loadErrorKey: 'localized-fallback', }) await crud.fetch() expect(crud.errorMessage.value).toBe('localized-fallback') }) it('non-array response — items defaults to []', async () => { const services = makeServices({ list: vi.fn().mockResolvedValue({ count: 0, results: [] }) }) const crud = useCrudList({ list: services.list, create: services.create, update: services.update, deleteFn: services.deleteFn, idKey: 'student_id', }) await crud.fetch() expect(crud.items.value).toEqual([]) }) }) // ─── applySaved ────────────────────────────────────────────────────────── describe('applySaved', () => { it('creates a new row when id is unknown — returns true', () => { const { crud } = makeCrud() const created = crud.applySaved({ student_id: 1, name: 'A' }) expect(created).toBe(true) expect(crud.items.value).toHaveLength(1) expect(crud.items.value[0]).toEqual({ student_id: 1, name: 'A' }) }) it('updates an existing row in place — returns false', () => { const { crud } = makeCrud() crud.items.value = [{ student_id: 1, name: 'old' }] const updated = crud.applySaved({ student_id: 1, name: 'new', extra: 'x' }) expect(updated).toBe(false) expect(crud.items.value).toHaveLength(1) expect(crud.items.value[0]).toEqual({ student_id: 1, name: 'new', extra: 'x' }) }) it('preserves backend audit fields (D5 fix)', () => { const { crud } = makeCrud() crud.items.value = [{ student_id: 1, name: 'A' }] // no audit fields crud.applySaved({ student_id: 1, name: 'A', last_updated_by: 'alice', last_update_date: '2026-07-19', }) expect(crud.items.value[0]).toMatchObject({ last_updated_by: 'alice', last_update_date: '2026-07-19', }) }) it('applies mapRow on the merged result', () => { const { crud } = makeCrud({ mapRow: (r) => ({ ...r, display: r.name?.toUpperCase() ?? null }), }) crud.items.value = [{ student_id: 1, name: 'a' }] crud.applySaved({ student_id: 1, name: 'b' }) expect(crud.items.value[0].display).toBe('B') }) it('is a no-op when entity is missing the idKey', () => { const { crud } = makeCrud() crud.items.value = [{ student_id: 1, name: 'A' }] const created = crud.applySaved({ name: 'no-id' }) expect(created).toBe(false) expect(crud.items.value).toHaveLength(1) expect(crud.items.value[0].name).toBe('A') }) it('is a no-op when entity is null/undefined', () => { const { crud } = makeCrud() crud.items.value = [{ student_id: 1, name: 'A' }] expect(crud.applySaved(null)).toBe(false) expect(crud.applySaved(undefined)).toBe(false) expect(crud.items.value).toHaveLength(1) }) }) // ─── applyDeleted ──────────────────────────────────────────────────────── describe('applyDeleted', () => { it('removes an existing id', () => { const { crud } = makeCrud() crud.items.value = [ { student_id: 1, name: 'A' }, { student_id: 2, name: 'B' }, ] crud.applyDeleted(1) expect(crud.items.value).toEqual([{ student_id: 2, name: 'B' }]) }) it('is a no-op for an unknown id', () => { const { crud } = makeCrud() crud.items.value = [{ student_id: 1, name: 'A' }] crud.applyDeleted(99) expect(crud.items.value).toHaveLength(1) }) it('does NOT touch deleteError (D7 — that is the delete dialog\'s ref)', () => { const { crud } = makeCrud() crud.applyDeleted(99) expect(crud.deleteError.value).toBe('') }) }) // ─── toggle methods ────────────────────────────────────────────────────── describe('toggle methods', () => { it('openEdit — sets editingItem + opens dialog', () => { const { crud } = makeCrud() const item = { student_id: 1, name: 'A' } crud.openEdit(item) expect(crud.editingItem.value).toEqual(item) expect(crud.editingDialogOpen.value).toBe(true) expect(crud.deleteDialogOpen.value).toBe(false) }) it('openCreate — null editingItem + opens dialog', () => { const { crud } = makeCrud() crud.openCreate() expect(crud.editingItem.value).toBeNull() expect(crud.editingDialogOpen.value).toBe(true) }) it('openDelete — sets itemToDelete + opens dialog', () => { const { crud } = makeCrud() const item = { student_id: 1, name: 'A' } crud.openDelete(item) expect(crud.itemToDelete.value).toEqual(item) expect(crud.deleteDialogOpen.value).toBe(true) expect(crud.editingDialogOpen.value).toBe(false) }) it('closeEdit — clears dialog + editingItem', () => { const { crud } = makeCrud() crud.openEdit({ student_id: 1 }) crud.closeEdit() expect(crud.editingDialogOpen.value).toBe(false) expect(crud.editingItem.value).toBeNull() }) it('closeDelete — clears dialog + itemToDelete', () => { const { crud } = makeCrud() crud.openDelete({ student_id: 1 }) crud.closeDelete() expect(crud.deleteDialogOpen.value).toBe(false) expect(crud.itemToDelete.value).toBeNull() }) it('clearDeleteError — resets the ref', () => { const { crud } = makeCrud() crud.deleteError.value = 'some error' crud.clearDeleteError() expect(crud.deleteError.value).toBe('') }) }) // ─── idKey + mapRow together ───────────────────────────────────────────── describe('idKey + mapRow', () => { it('uses the configured idKey, not a hardcoded one', () => { const crud = useCrudList({ list: makeServices().list, create: makeServices().create, update: makeServices().update, deleteFn: makeServices().deleteFn, idKey: 'subject_id', mapRow: (r) => ({ ...r, display: r.subject_name?.toUpperCase() ?? null }), }) crud.items.value = [{ subject_id: 7, subject_name: 'math' }] const created = crud.applySaved({ subject_id: 8, subject_name: 'sci' }) expect(created).toBe(true) expect(crud.items.value[0]).toEqual({ subject_id: 8, subject_name: 'sci', display: 'SCI' }) crud.applySaved({ subject_id: 7, subject_name: 'maths' }) expect(crud.items.value[1]).toEqual({ subject_id: 7, subject_name: 'maths', display: 'MATHS', }) }) })