From de8e82bd93651824295c8c24d09fb0e59bfbc8be Mon Sep 17 00:00:00 2001 From: v-byte-cpu <65545655+v-byte-cpu@users.noreply.github.com> Date: Sun, 7 Jun 2026 02:24:44 +0400 Subject: [PATCH] test(ui): add MSW coverage for web service adapters Add an opt-in MSW test helper for generated Clear API web services and use it to cover platform web adapters at the HTTP boundary. Document the same service-adapter testing pattern in the React/Vite structure skill so future web service tests follow the same approach. --- skills/react-vite-structure/SKILL.md | 2 +- .../practices-testing-state-quality.md | 75 +++++++ .../bootstrap/web/bootstrapService.test.ts | 29 +++ .../web/contentSearchService.test.ts | 81 ++++++++ .../services/decks/web/deckService.test.ts | 193 ++++++++++++++---- .../folders/web/folderService.test.ts | 165 ++++++++++----- .../services/notes/web/noteService.test.ts | 185 ++++++++++------- .../services/review/web/reviewService.test.ts | 108 ++++++++++ .../settings/web/settingsService.test.ts | 68 ++++++ .../services/trash/web/trashService.test.ts | 70 +++++++ .../workspaces/web/workspaceService.test.ts | 131 ++++++++++++ ui/src/test/web-api-msw.ts | 49 +++++ 12 files changed, 998 insertions(+), 158 deletions(-) create mode 100644 ui/src/platform/services/bootstrap/web/bootstrapService.test.ts create mode 100644 ui/src/platform/services/content-search/web/contentSearchService.test.ts create mode 100644 ui/src/platform/services/review/web/reviewService.test.ts create mode 100644 ui/src/platform/services/settings/web/settingsService.test.ts create mode 100644 ui/src/platform/services/trash/web/trashService.test.ts create mode 100644 ui/src/platform/services/workspaces/web/workspaceService.test.ts create mode 100644 ui/src/test/web-api-msw.ts diff --git a/skills/react-vite-structure/SKILL.md b/skills/react-vite-structure/SKILL.md index b65e99d..2c6e176 100644 --- a/skills/react-vite-structure/SKILL.md +++ b/skills/react-vite-structure/SKILL.md @@ -25,7 +25,7 @@ Use this skill to organize React + Vite + TypeScript apps around feature modules - Read `references/ui-error-states.md` for UI loading, empty, query error, partial-data, retry, mutation pending, and mutation error rendering policy. - Read `references/typescript-and-naming.md` for `tsconfig.json`, `vite.config.ts` path aliases, naming conventions, component/hook/service/type examples, and common shared types. - Read `references/feature-workflow.md` when adding or scaffolding a new feature module, including types, service, React Query hooks, components, pages, and public exports. -- Read `references/practices-testing-state-quality.md` for component organization, type safety, custom hooks, error handling, environment variables, unit/integration tests, React Query, Zustand, Context API, ESLint, Prettier, and lint-staged guidance. +- Read `references/practices-testing-state-quality.md` for component organization, type safety, custom hooks, error handling, environment variables, unit/integration tests, MSW HTTP-boundary service adapter tests, React Query, Zustand, Context API, ESLint, Prettier, and lint-staged guidance. - Read `references/storybook.md` when adding or changing UI components/pages, Storybook setup, visual/a11y/interaction test coverage, Storybook decorators, MSW handlers, or shared Storybook harnesses. - Read `references/documentation-templates.md` when creating feature documentation, changelogs, API docs, component docs, or troubleshooting guides. - Read `references/project-checklist-and-migration.md` for new-project setup checklists, additional resources, gradual migration phases, and implementation tips. diff --git a/skills/react-vite-structure/references/practices-testing-state-quality.md b/skills/react-vite-structure/references/practices-testing-state-quality.md index 17c7099..ad619cb 100644 --- a/skills/react-vite-structure/references/practices-testing-state-quality.md +++ b/skills/react-vite-structure/references/practices-testing-state-quality.md @@ -252,6 +252,81 @@ When a feature uses injected services: - Test `shared/errors/*`, `shared/services/api/error-mapping.test.ts`, `platform/tauri/tauri-error.test.ts`, and `core/query/domain-query.test.ts` near their implementations. - Avoid mocking web/Tauri transport directly in React components when service injection is available. +### Web Service Adapter Tests + +Test `platform/services/*/web/*.test.ts` at the HTTP boundary when a web adapter wraps a generated SDK or shared API client. Prefer MSW over mocking generated SDK functions when the test should prove URL building, path/query/body serialization, response validation, and API error mapping. + +Keep MSW opt-in unless most tests need HTTP interception. Put shared helpers under `src/test/`, return a file-local server from the setup helper, and use strict unhandled-request behavior: + +```typescript +// src/test/web-api-msw.ts +import { afterAll, afterEach, beforeAll } from 'vitest'; +import { setupServer } from 'msw/node'; + +export const WEB_API_BASE_URL = 'http://app.test/api/v1'; +export const apiUrl = (path: `/${string}`) => `${WEB_API_BASE_URL}${path}`; + +export const setupWebApiMsw = () => { + const server = setupServer(); + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + return server; +}; +``` + +If the generated client keeps global configuration, set the test base URL in the helper and restore the original config in `afterAll`. + +Prefer handlers that assert transport details instead of only returning static JSON: + +```typescript +// src/platform/services/products/web/productService.test.ts +import { describe, expect, it } from 'vitest'; +import { http, HttpResponse } from 'msw'; + +import { apiUrl, setupWebApiMsw } from '@/test/web-api-msw'; + +import { webProductService } from './productService'; + +const server = setupWebApiMsw(); + +const product = { + id: 'notebook', + name: 'Notebook', + updatedAt: '2026-05-15T12:00:00.000Z', +}; + +describe('webProductService', () => { + it('lists category products with path and sort query params', async () => { + server.use( + http.get(apiUrl('/categories/:categoryId/products'), ({ params, request }) => { + const url = new URL(request.url); + + expect(params.categoryId).toBe('stationery'); + expect(url.searchParams.get('sortDirection')).toBe('desc'); + expect(url.searchParams.get('sortField')).toBe('updated'); + + return HttpResponse.json([product]); + }), + ); + + await expect( + webProductService.listCategoryProducts('stationery', { + direction: 'desc', + field: 'updated', + }), + ).resolves.toEqual({ + ok: true, + value: [product], + }); + }); +}); +``` + +Cover each public web adapter method with focused success tests. Include representative tests for request bodies, `204`/void responses, API errors, and malformed successful responses when the client performs runtime response validation. Keep UI, hook, and page tests on injected fake services unless the test intentionally exercises the HTTP boundary. + --- ## State Management Options diff --git a/ui/src/platform/services/bootstrap/web/bootstrapService.test.ts b/ui/src/platform/services/bootstrap/web/bootstrapService.test.ts new file mode 100644 index 0000000..5c30cb3 --- /dev/null +++ b/ui/src/platform/services/bootstrap/web/bootstrapService.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { http, HttpResponse } from 'msw' + +import type { BootstrapResult } from '@api-generated/clear-api' +import { apiUrl, setupWebApiMsw } from '@/test/web-api-msw' + +import { webBootstrapService } from './bootstrapService' + +const server = setupWebApiMsw() + +const bootstrapResult = { + runtimeProfile: { + formFactor: 'desktop', + runtime: 'web', + }, +} satisfies BootstrapResult + +describe('webBootstrapService', () => { + it('bootstraps runtime data through the web API', async () => { + server.use( + http.post(apiUrl('/bootstrap'), () => HttpResponse.json(bootstrapResult)), + ) + + await expect(webBootstrapService.bootstrap()).resolves.toEqual({ + ok: true, + value: bootstrapResult, + }) + }) +}) diff --git a/ui/src/platform/services/content-search/web/contentSearchService.test.ts b/ui/src/platform/services/content-search/web/contentSearchService.test.ts new file mode 100644 index 0000000..136c681 --- /dev/null +++ b/ui/src/platform/services/content-search/web/contentSearchService.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { http, HttpResponse } from 'msw' + +import type { SearchResultGroup, SearchScope } from '@api-generated/clear-api' +import { apiUrl, setupWebApiMsw } from '@/test/web-api-msw' + +import { webContentSearchService } from './contentSearchService' + +const server = setupWebApiMsw() + +const scope = { + kind: 'workspace', + workspaceId: 'independent-study', +} satisfies SearchScope + +const searchGroups = [ + { + kind: 'folder', + results: [ + { + id: 'reading-notes', + kind: 'folder', + locationPath: ['Independent Study'], + title: 'Reading Notes', + updatedAt: '2026-05-15T12:00:00.000Z', + workspaceId: 'independent-study', + }, + ], + }, + { + kind: 'deck', + results: [ + { + deckIcon: 'book-open', + id: 'world-history', + kind: 'deck', + locationPath: ['Independent Study', 'Reading Notes'], + title: 'World History', + updatedAt: '2026-05-15T12:00:00.000Z', + workspaceId: 'independent-study', + }, + ], + }, + { + kind: 'note', + results: [ + { + deckId: 'world-history', + id: 'industrial-revolution-causes', + kind: 'note', + locationPath: ['Independent Study', 'Reading Notes', 'World History'], + noteKind: 'basic', + title: 'Industrial Revolution Causes', + updatedAt: '2026-05-12T12:00:00.000Z', + workspaceId: 'independent-study', + }, + ], + }, +] satisfies SearchResultGroup[] + +describe('webContentSearchService', () => { + it('searches content through the web API and maps all result groups', async () => { + server.use( + http.post(apiUrl('/search'), async ({ request }) => { + expect(await request.json()).toEqual({ + query: 'history', + scope, + }) + + return HttpResponse.json(searchGroups) + }), + ) + + await expect( + webContentSearchService.search(scope, 'history'), + ).resolves.toEqual({ + ok: true, + value: searchGroups, + }) + }) +}) diff --git a/ui/src/platform/services/decks/web/deckService.test.ts b/ui/src/platform/services/decks/web/deckService.test.ts index 0eaca19..d4bfab2 100644 --- a/ui/src/platform/services/decks/web/deckService.test.ts +++ b/ui/src/platform/services/decks/web/deckService.test.ts @@ -1,13 +1,18 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' +import { http, HttpResponse } from 'msw' -const apiMocks = vi.hoisted(() => ({ - createDeck: vi.fn(), - deleteDeck: vi.fn(), - getDeck: vi.fn(), - listFolderDecks: vi.fn(), - listWorkspaceDecks: vi.fn(), - updateDeck: vi.fn(), -})) +import type { Deck, DeckDraft } from '@api-generated/clear-api' +import { DomainErrorType } from '@shared/errors' +import { + apiUrl, + expectErr, + expectOk, + setupWebApiMsw, +} from '@/test/web-api-msw' + +import { webDeckService } from './deckService' + +const server = setupWebApiMsw() const deck = { description: 'Global institutions.', @@ -20,48 +25,160 @@ const deck = { totalNotes: 3, updatedAt: '2026-05-15T12:00:00.000Z', workspaceId: 'independent-study', -} +} satisfies Deck -const loadWebDeckService = async () => { - vi.doMock('@api-generated/clear-api', () => apiMocks) - - return (await import('./deckService')).webDeckService -} +const draft = { + description: 'Updated global institutions.', + icon: 'book-open', + parentId: 'reading-notes', + title: 'World History Updated', +} as const satisfies DeckDraft describe('webDeckService', () => { - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() + it('creates decks through the web API', async () => { + server.use( + http.post(apiUrl('/decks'), async ({ request }) => { + expect(await request.json()).toEqual(draft) + + return HttpResponse.json(deck, { status: 201 }) + }), + ) + + await expect(webDeckService.create(draft)).resolves.toEqual({ + ok: true, + value: deck, + }) }) - it('uses the workspace endpoint for root decks', async () => { - apiMocks.listWorkspaceDecks.mockResolvedValue({ data: [deck] }) - const webDeckService = await loadWebDeckService() + it('moves decks to trash through the web API', async () => { + server.use( + http.delete(apiUrl('/decks/:deckId'), ({ params }) => { + expect(params.deckId).toBe('world-history') - const result = await webDeckService.listWorkspaceRoot('independent-study', { - direction: 'desc', - field: 'updated', + return new HttpResponse(null, { status: 204 }) + }), + ) + + expectOk(await webDeckService.delete('world-history')) + }) + + it('loads a deck by id through the web API', async () => { + server.use( + http.get(apiUrl('/decks/:deckId'), ({ params }) => { + expect(params.deckId).toBe('world-history') + + return HttpResponse.json(deck) + }), + ) + + await expect(webDeckService.getById('world-history')).resolves.toEqual({ + ok: true, + value: deck, }) + }) + + it('lists folder decks with sort query params', async () => { + server.use( + http.get(apiUrl('/folders/:folderId/decks'), ({ params, request }) => { + const url = new URL(request.url) + + expect(params.folderId).toBe('reading-notes') + expect(url.searchParams.get('sortDirection')).toBe('desc') + expect(url.searchParams.get('sortField')).toBe('updated') - expect(result.ok ? result.value : []).toEqual([deck]) - expect(apiMocks.listWorkspaceDecks).toHaveBeenCalledWith({ - path: { workspaceId: 'independent-study' }, - query: { sortDirection: 'desc', sortField: 'updated' }, + return HttpResponse.json([deck]) + }), + ) + + await expect( + webDeckService.listFolderChildren('reading-notes', { + direction: 'desc', + field: 'updated', + }), + ).resolves.toEqual({ + ok: true, + value: [deck], + }) + }) + + it('lists workspace root decks with sort query params', async () => { + server.use( + http.get(apiUrl('/workspaces/:workspaceId/decks'), ({ params, request }) => { + const url = new URL(request.url) + + expect(params.workspaceId).toBe('independent-study') + expect(url.searchParams.get('sortDirection')).toBe('asc') + expect(url.searchParams.get('sortField')).toBe('title') + + return HttpResponse.json([deck]) + }), + ) + + await expect( + webDeckService.listWorkspaceRoot('independent-study', { + direction: 'asc', + field: 'title', + }), + ).resolves.toEqual({ + ok: true, + value: [deck], }) - expect(apiMocks.listFolderDecks).not.toHaveBeenCalled() }) - it('uses the folder endpoint for folder decks', async () => { - apiMocks.listFolderDecks.mockResolvedValue({ data: [deck] }) - const webDeckService = await loadWebDeckService() + it('updates decks through the web API', async () => { + server.use( + http.put(apiUrl('/decks/:deckId'), async ({ params, request }) => { + expect(params.deckId).toBe('world-history') + expect(await request.json()).toEqual(draft) + + return HttpResponse.json({ ...deck, ...draft }) + }), + ) + + await expect(webDeckService.update('world-history', draft)).resolves.toEqual({ + ok: true, + value: { ...deck, ...draft }, + }) + }) + + it('maps API validation errors to domain validation errors', async () => { + server.use( + http.post(apiUrl('/decks'), () => + HttpResponse.json( + { + fieldErrors: { + title: ['Title is required.'], + }, + message: 'Invalid deck.', + retryable: false, + type: DomainErrorType.Validation, + }, + { status: 422 }, + ), + ), + ) + + expect(expectErr(await webDeckService.create(draft))).toEqual({ + fieldErrors: { + title: ['Title is required.'], + }, + message: 'Invalid deck.', + retryable: false, + type: DomainErrorType.Validation, + }) + }) - const result = await webDeckService.listFolderChildren('reading-notes') + it('maps malformed success responses to service unavailable', async () => { + server.use( + http.get(apiUrl('/decks/:deckId'), () => + HttpResponse.json({ id: 'not-a-deck' }), + ), + ) - expect(result.ok ? result.value : []).toEqual([deck]) - expect(apiMocks.listFolderDecks).toHaveBeenCalledWith({ - path: { folderId: 'reading-notes' }, - query: {}, + expect(expectErr(await webDeckService.getById('world-history'))).toMatchObject({ + message: 'Failed to load deck.', + retryable: true, + type: DomainErrorType.Unavailable, }) - expect(apiMocks.listWorkspaceDecks).not.toHaveBeenCalled() }) }) diff --git a/ui/src/platform/services/folders/web/folderService.test.ts b/ui/src/platform/services/folders/web/folderService.test.ts index 721d548..4057198 100644 --- a/ui/src/platform/services/folders/web/folderService.test.ts +++ b/ui/src/platform/services/folders/web/folderService.test.ts @@ -1,14 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const apiMocks = vi.hoisted(() => ({ - createFolder: vi.fn(), - deleteFolder: vi.fn(), - getFolder: vi.fn(), - getFolderPath: vi.fn(), - listFolderFolders: vi.fn(), - listWorkspaceFolders: vi.fn(), - updateFolder: vi.fn(), -})) +import { describe, expect, it } from 'vitest' +import { http, HttpResponse } from 'msw' + +import type { Folder, FolderDraft } from '@api-generated/clear-api' +import { apiUrl, expectOk, setupWebApiMsw } from '@/test/web-api-msw' + +import { webFolderService } from './folderService' + +const server = setupWebApiMsw() const folder = { description: 'Reference materials.', @@ -17,62 +15,133 @@ const folder = { parentId: 'independent-study', updatedAt: '2026-05-15T12:00:00.000Z', workspaceId: 'independent-study', -} +} satisfies Folder -const loadWebFolderService = async () => { - vi.doMock('@api-generated/clear-api', () => apiMocks) - - return (await import('./folderService')).webFolderService -} +const draft = { + description: 'Updated reference materials.', + name: 'Reading Notes Updated', + parentId: 'independent-study', +} satisfies FolderDraft describe('webFolderService', () => { - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() + it('creates folders through the web API', async () => { + server.use( + http.post(apiUrl('/folders'), async ({ request }) => { + expect(await request.json()).toEqual(draft) + + return HttpResponse.json(folder, { status: 201 }) + }), + ) + + await expect(webFolderService.create(draft)).resolves.toEqual({ + ok: true, + value: folder, + }) + }) + + it('moves folders to trash through the web API', async () => { + server.use( + http.delete(apiUrl('/folders/:folderId'), ({ params }) => { + expect(params.folderId).toBe('reading-notes') + + return new HttpResponse(null, { status: 204 }) + }), + ) + + expectOk(await webFolderService.delete('reading-notes')) }) - it('uses the workspace endpoint for root folders', async () => { - apiMocks.listWorkspaceFolders.mockResolvedValue({ data: [folder] }) - const webFolderService = await loadWebFolderService() + it('loads a folder by id through the web API', async () => { + server.use( + http.get(apiUrl('/folders/:folderId'), ({ params }) => { + expect(params.folderId).toBe('reading-notes') - const result = await webFolderService.listWorkspaceRoot('independent-study', { - direction: 'asc', - field: 'title', + return HttpResponse.json(folder) + }), + ) + + await expect(webFolderService.getById('reading-notes')).resolves.toEqual({ + ok: true, + value: folder, }) + }) + + it('maps folder path response segments', async () => { + server.use( + http.get(apiUrl('/folders/:folderId/path'), ({ params }) => { + expect(params.folderId).toBe('history') + + return HttpResponse.json({ segments: ['Reading Notes', 'History'] }) + }), + ) - expect(result.ok ? result.value : []).toEqual([folder]) - expect(apiMocks.listWorkspaceFolders).toHaveBeenCalledWith({ - path: { workspaceId: 'independent-study' }, - query: { sortDirection: 'asc', sortField: 'title' }, + await expect(webFolderService.getPath('history')).resolves.toEqual({ + ok: true, + value: ['Reading Notes', 'History'], }) - expect(apiMocks.listFolderFolders).not.toHaveBeenCalled() }) - it('uses the folder endpoint for nested folders', async () => { - apiMocks.listFolderFolders.mockResolvedValue({ data: [folder] }) - const webFolderService = await loadWebFolderService() + it('lists nested folders with sort query params', async () => { + server.use( + http.get(apiUrl('/folders/:folderId/folders'), ({ params, request }) => { + const url = new URL(request.url) + + expect(params.folderId).toBe('reading-notes') + expect(url.searchParams.get('sortDirection')).toBe('desc') + expect(url.searchParams.get('sortField')).toBe('updated') - const result = await webFolderService.listFolderChildren('reading-notes') + return HttpResponse.json([folder]) + }), + ) - expect(result.ok ? result.value : []).toEqual([folder]) - expect(apiMocks.listFolderFolders).toHaveBeenCalledWith({ - path: { folderId: 'reading-notes' }, - query: {}, + await expect( + webFolderService.listFolderChildren('reading-notes', { + direction: 'desc', + field: 'updated', + }), + ).resolves.toEqual({ + ok: true, + value: [folder], }) - expect(apiMocks.listWorkspaceFolders).not.toHaveBeenCalled() }) - it('maps folder path response segments', async () => { - apiMocks.getFolderPath.mockResolvedValue({ - data: { segments: ['Reading Notes', 'History'] }, + it('lists workspace root folders with sort query params', async () => { + server.use( + http.get(apiUrl('/workspaces/:workspaceId/folders'), ({ params, request }) => { + const url = new URL(request.url) + + expect(params.workspaceId).toBe('independent-study') + expect(url.searchParams.get('sortDirection')).toBe('asc') + expect(url.searchParams.get('sortField')).toBe('title') + + return HttpResponse.json([folder]) + }), + ) + + await expect( + webFolderService.listWorkspaceRoot('independent-study', { + direction: 'asc', + field: 'title', + }), + ).resolves.toEqual({ + ok: true, + value: [folder], }) - const webFolderService = await loadWebFolderService() + }) + + it('updates folders through the web API', async () => { + server.use( + http.put(apiUrl('/folders/:folderId'), async ({ params, request }) => { + expect(params.folderId).toBe('reading-notes') + expect(await request.json()).toEqual(draft) - const result = await webFolderService.getPath('history') + return HttpResponse.json({ ...folder, ...draft }) + }), + ) - expect(result.ok ? result.value : []).toEqual(['Reading Notes', 'History']) - expect(apiMocks.getFolderPath).toHaveBeenCalledWith({ - path: { folderId: 'history' }, + await expect(webFolderService.update('reading-notes', draft)).resolves.toEqual({ + ok: true, + value: { ...folder, ...draft }, }) }) }) diff --git a/ui/src/platform/services/notes/web/noteService.test.ts b/ui/src/platform/services/notes/web/noteService.test.ts index d53df3e..769ce37 100644 --- a/ui/src/platform/services/notes/web/noteService.test.ts +++ b/ui/src/platform/services/notes/web/noteService.test.ts @@ -1,15 +1,19 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' +import { http, HttpResponse } from 'msw' -const apiMocks = vi.hoisted(() => ({ - createNote: vi.fn(), - deleteNote: vi.fn(), - getNote: vi.fn(), - listNotesByDeck: vi.fn(), - updateNote: vi.fn(), -})) +import type { + NoteDetail, + NoteDraft, + NoteListItem, + NoteRef, +} from '@api-generated/clear-api' +import { apiUrl, expectOk, setupWebApiMsw } from '@/test/web-api-msw' -const note = { - deckId: 'world-history', +import { webNoteService } from './noteService' + +const server = setupWebApiMsw() + +const noteListItem = { dueAt: '2026-05-16T12:00:00.000Z', id: 'industrial-revolution-causes', kind: 'basic', @@ -18,80 +22,119 @@ const note = { status: 'mastered', title: 'Industrial Revolution Causes', updatedAt: '2026-05-12T12:00:00.000Z', -} +} satisfies NoteListItem -const noteRef = { - deckId: note.deckId, - id: note.id, -} +const noteDetail = { + deckId: 'world-history', + dueAt: noteListItem.dueAt, + editor: { back: 'Back', front: 'Front' }, + id: noteListItem.id, + kind: 'basic', + progress: noteListItem.progress, + reviewedAt: noteListItem.reviewedAt, + status: noteListItem.status, + title: noteListItem.title, + updatedAt: noteListItem.updatedAt, +} satisfies NoteDetail -const loadWebNoteService = async () => { - vi.doMock('@api-generated/clear-api', () => apiMocks) +const noteRef = { + deckId: 'world-history', + id: noteListItem.id, +} satisfies NoteRef - return (await import('./noteService')).webNoteService -} +const draft = { + deckId: 'world-history', + editor: { back: 'Updated back', front: 'Updated front' }, + kind: 'basic', + title: 'Updated Industrial Revolution Causes', +} satisfies NoteDraft describe('webNoteService', () => { - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() + it('creates notes through the web API and returns a slim note ref', async () => { + server.use( + http.post(apiUrl('/notes'), async ({ request }) => { + expect(await request.json()).toEqual(draft) + + return HttpResponse.json(noteRef, { status: 201 }) + }), + ) + + const result = expectOk(await webNoteService.create(draft)) + + expect(result).toEqual(noteRef) + expect(result).not.toHaveProperty('editor') + expect(result).not.toHaveProperty('progress') }) - it('lists deck notes with the slim list item response', async () => { - apiMocks.listNotesByDeck.mockResolvedValue({ data: [note] }) - const webNoteService = await loadWebNoteService() + it('moves notes to trash through the web API', async () => { + server.use( + http.delete(apiUrl('/notes/:noteId'), ({ params }) => { + expect(params.noteId).toBe(noteListItem.id) - const result = await webNoteService.listByDeck('world-history', { - direction: 'desc', - field: 'updated', - }) + return new HttpResponse(null, { status: 204 }) + }), + ) + + expectOk(await webNoteService.delete(noteListItem.id)) + }) + + it('loads note detail by note id through the web API', async () => { + server.use( + http.get(apiUrl('/notes/:noteId'), ({ params }) => { + expect(params.noteId).toBe(noteListItem.id) - expect(result.ok ? result.value : []).toEqual([note]) - expect(result.ok ? result.value[0] : undefined).not.toHaveProperty('editor') - expect(result.ok ? result.value[0] : undefined).not.toHaveProperty('bodySegments') - expect(result.ok ? result.value[0] : undefined).not.toHaveProperty('cards') - expect(apiMocks.listNotesByDeck).toHaveBeenCalledWith({ - path: { deckId: 'world-history' }, - query: { sortDirection: 'desc', sortField: 'updated' }, + return HttpResponse.json(noteDetail) + }), + ) + + await expect( + webNoteService.getById('ignored-by-web-service', noteListItem.id), + ).resolves.toEqual({ + ok: true, + value: noteDetail, }) }) - it('creates notes with the slim note ref response', async () => { - apiMocks.createNote.mockResolvedValue({ data: noteRef }) - const webNoteService = await loadWebNoteService() - const draft = { - deckId: 'world-history', - editor: { back: 'Back', front: 'Front' }, - kind: 'basic' as const, - title: 'Industrial Revolution Causes', - } - - const result = await webNoteService.create(draft) - - expect(result.ok ? result.value : undefined).toEqual(noteRef) - expect(result.ok ? result.value : undefined).not.toHaveProperty('editor') - expect(result.ok ? result.value : undefined).not.toHaveProperty('progress') - expect(apiMocks.createNote).toHaveBeenCalledWith({ body: draft }) + it('lists deck notes with sort query params and slim list items', async () => { + server.use( + http.get(apiUrl('/decks/:deckId/notes'), ({ params, request }) => { + const url = new URL(request.url) + + expect(params.deckId).toBe('world-history') + expect(url.searchParams.get('sortDirection')).toBe('desc') + expect(url.searchParams.get('sortField')).toBe('updated') + + return HttpResponse.json([noteListItem]) + }), + ) + + const result = expectOk( + await webNoteService.listByDeck('world-history', { + direction: 'desc', + field: 'updated', + }), + ) + + expect(result).toEqual([noteListItem]) + expect(result[0]).not.toHaveProperty('editor') + expect(result[0]).not.toHaveProperty('bodySegments') + expect(result[0]).not.toHaveProperty('cards') }) - it('updates notes with the slim note ref response', async () => { - apiMocks.updateNote.mockResolvedValue({ data: noteRef }) - const webNoteService = await loadWebNoteService() - const draft = { - deckId: 'world-history', - editor: { back: 'Updated back', front: 'Updated front' }, - kind: 'basic' as const, - title: 'Updated Industrial Revolution Causes', - } - - const result = await webNoteService.update(note.id, draft) - - expect(result.ok ? result.value : undefined).toEqual(noteRef) - expect(result.ok ? result.value : undefined).not.toHaveProperty('editor') - expect(result.ok ? result.value : undefined).not.toHaveProperty('progress') - expect(apiMocks.updateNote).toHaveBeenCalledWith({ - body: draft, - path: { noteId: note.id }, - }) + it('updates notes through the web API and returns a slim note ref', async () => { + server.use( + http.put(apiUrl('/notes/:noteId'), async ({ params, request }) => { + expect(params.noteId).toBe(noteListItem.id) + expect(await request.json()).toEqual(draft) + + return HttpResponse.json(noteRef) + }), + ) + + const result = expectOk(await webNoteService.update(noteListItem.id, draft)) + + expect(result).toEqual(noteRef) + expect(result).not.toHaveProperty('editor') + expect(result).not.toHaveProperty('progress') }) }) diff --git a/ui/src/platform/services/review/web/reviewService.test.ts b/ui/src/platform/services/review/web/reviewService.test.ts new file mode 100644 index 0000000..95213d2 --- /dev/null +++ b/ui/src/platform/services/review/web/reviewService.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import { http, HttpResponse } from 'msw' + +import type { + ReviewCard, + ReviewSession, + ReviewStartResult, +} from '@api-generated/clear-api' +import { apiUrl, setupWebApiMsw } from '@/test/web-api-msw' + +import { webReviewService } from './reviewService' + +const server = setupWebApiMsw() + +const basicCard = { + back: 'Back', + front: 'Front', + id: 'card-1', + kind: 'basic', + progress: 42, +} satisfies ReviewCard + +const dueSession = { + currentCard: basicCard, + deckId: 'world-history', + durationSeconds: 120, + id: 'review-1', + mode: 'due', + plannedCount: 10, + reviewedCount: 3, + startedAt: '2026-05-20T12:00:00.000Z', + status: 'active', +} satisfies ReviewSession + +const practiceSession = { + currentCard: { + body: 'The {{c1::Industrial Revolution}} started in Britain.', + clozeId: 'c1', + id: 'card-2', + kind: 'cloze', + progress: 12, + }, + deckId: 'world-history', + durationSeconds: 180, + id: 'review-2', + mode: 'practice', + reviewedCount: 5, + startedAt: '2026-05-20T13:00:00.000Z', +} satisfies ReviewSession + +const unavailableStart = { + mode: 'unavailable', + reason: 'empty-deck', +} satisfies ReviewStartResult + +describe('webReviewService', () => { + it('starts review sessions through the web API', async () => { + server.use( + http.post(apiUrl('/decks/:deckId/reviews'), ({ params }) => { + expect(params.deckId).toBe('empty-deck') + + return HttpResponse.json(unavailableStart, { status: 201 }) + }), + ) + + await expect(webReviewService.start('empty-deck')).resolves.toEqual({ + ok: true, + value: unavailableStart, + }) + }) + + it('loads due review sessions through the web API', async () => { + server.use( + http.get(apiUrl('/reviews/:reviewId'), ({ params }) => { + expect(params.reviewId).toBe(dueSession.id) + + return HttpResponse.json(dueSession) + }), + ) + + await expect(webReviewService.get(dueSession.id)).resolves.toEqual({ + ok: true, + value: dueSession, + }) + }) + + it('grades review cards through the web API', async () => { + server.use( + http.post( + apiUrl('/reviews/:reviewId/cards/:cardId/grade'), + async ({ params, request }) => { + expect(params.reviewId).toBe(practiceSession.id) + expect(params.cardId).toBe('card-2') + expect(await request.json()).toEqual({ grade: 'good' }) + + return HttpResponse.json(practiceSession) + }, + ), + ) + + await expect( + webReviewService.grade(practiceSession.id, 'card-2', 'good'), + ).resolves.toEqual({ + ok: true, + value: practiceSession, + }) + }) +}) diff --git a/ui/src/platform/services/settings/web/settingsService.test.ts b/ui/src/platform/services/settings/web/settingsService.test.ts new file mode 100644 index 0000000..c500d91 --- /dev/null +++ b/ui/src/platform/services/settings/web/settingsService.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { http, HttpResponse } from 'msw' + +import type { Settings } from '@api-generated/clear-api' +import { apiUrl, setupWebApiMsw } from '@/test/web-api-msw' + +import { webSettingsService } from './settingsService' + +const server = setupWebApiMsw() + +const settings = { + dailyNewLimit: 20, + dailyReviewLimit: 120, + fsrsParams: [0.4, 0.6, 2.4], + fsrsRetention: 0.9, + language: 'en-US', + masteryHorizonDays: 30, + newCardsOrder: 'mixed', + timezone: 'UTC', +} satisfies Settings + +describe('webSettingsService', () => { + it('loads default settings through the web API', async () => { + server.use( + http.get(apiUrl('/settings/defaults'), () => HttpResponse.json(settings)), + ) + + await expect(webSettingsService.getDefaults()).resolves.toEqual({ + ok: true, + value: settings, + }) + }) + + it('reads settings through the web API', async () => { + server.use(http.get(apiUrl('/settings'), () => HttpResponse.json(settings))) + + await expect(webSettingsService.read()).resolves.toEqual({ + ok: true, + value: settings, + }) + }) + + it('resets settings through the web API', async () => { + server.use( + http.post(apiUrl('/settings/reset'), () => HttpResponse.json(settings)), + ) + + await expect(webSettingsService.reset()).resolves.toEqual({ + ok: true, + value: settings, + }) + }) + + it('writes settings through the web API', async () => { + server.use( + http.put(apiUrl('/settings'), async ({ request }) => { + expect(await request.json()).toEqual(settings) + + return HttpResponse.json(settings) + }), + ) + + await expect(webSettingsService.write(settings)).resolves.toEqual({ + ok: true, + value: settings, + }) + }) +}) diff --git a/ui/src/platform/services/trash/web/trashService.test.ts b/ui/src/platform/services/trash/web/trashService.test.ts new file mode 100644 index 0000000..7f954ee --- /dev/null +++ b/ui/src/platform/services/trash/web/trashService.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { http, HttpResponse } from 'msw' + +import type { TrashState } from '@api-generated/clear-api' +import { apiUrl, expectOk, setupWebApiMsw } from '@/test/web-api-msw' + +import { webTrashService } from './trashService' + +const server = setupWebApiMsw() + +const trashState = { + items: [ + { + deletedAt: '2026-05-18T12:00:00.000Z', + id: 'deleted-world-history', + kind: 'deck', + locationPath: ['Independent Study'], + title: 'World History', + }, + ], + lastEmptiedAt: '2026-05-01T12:00:00.000Z', +} satisfies TrashState + +describe('webTrashService', () => { + it('permanently deletes trash items through the web API', async () => { + server.use( + http.delete(apiUrl('/trash/items/:itemId'), ({ params }) => { + expect(params.itemId).toBe('deleted-world-history') + + return new HttpResponse(null, { status: 204 }) + }), + ) + + expectOk(await webTrashService.deleteItem('deleted-world-history')) + }) + + it('empties trash through the web API', async () => { + server.use( + http.delete(apiUrl('/trash'), () => + HttpResponse.json({ ...trashState, items: [] }), + ), + ) + + await expect(webTrashService.empty()).resolves.toEqual({ + ok: true, + value: { ...trashState, items: [] }, + }) + }) + + it('loads trash through the web API', async () => { + server.use(http.get(apiUrl('/trash'), () => HttpResponse.json(trashState))) + + await expect(webTrashService.list()).resolves.toEqual({ + ok: true, + value: trashState, + }) + }) + + it('restores trash items through the web API', async () => { + server.use( + http.post(apiUrl('/trash/items/:itemId/restore'), ({ params }) => { + expect(params.itemId).toBe('deleted-world-history') + + return new HttpResponse(null, { status: 204 }) + }), + ) + + expectOk(await webTrashService.restoreItem('deleted-world-history')) + }) +}) diff --git a/ui/src/platform/services/workspaces/web/workspaceService.test.ts b/ui/src/platform/services/workspaces/web/workspaceService.test.ts new file mode 100644 index 0000000..0be1f3b --- /dev/null +++ b/ui/src/platform/services/workspaces/web/workspaceService.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { http, HttpResponse } from 'msw' + +import type { + Workspace, + WorkspaceDraft, + WorkspaceListResult, +} from '@api-generated/clear-api' +import { apiUrl, expectOk, setupWebApiMsw } from '@/test/web-api-msw' + +import { webWorkspaceService } from './workspaceService' + +const server = setupWebApiMsw() + +const workspace = { + description: 'Independent study workspace.', + icon: 'brain', + id: 'independent-study', + title: 'Independent Study', + updatedAt: '2026-05-15T12:00:00.000Z', +} satisfies Workspace + +const draft = { + description: 'Updated study workspace.', + icon: 'brain', + title: 'Independent Study Updated', +} as const satisfies WorkspaceDraft + +const listResult = { + activeWorkspaceId: workspace.id, + workspaces: [workspace], +} satisfies WorkspaceListResult + +describe('webWorkspaceService', () => { + it('creates workspaces through the web API', async () => { + server.use( + http.post(apiUrl('/workspaces'), async ({ request }) => { + expect(await request.json()).toEqual(draft) + + return HttpResponse.json(workspace, { status: 201 }) + }), + ) + + await expect(webWorkspaceService.create(draft)).resolves.toEqual({ + ok: true, + value: workspace, + }) + }) + + it('moves workspaces to trash and maps the next active workspace id', async () => { + server.use( + http.delete(apiUrl('/workspaces/:workspaceId'), ({ params }) => { + expect(params.workspaceId).toBe(workspace.id) + + return HttpResponse.json({ activeWorkspaceId: null }) + }), + ) + + await expect(webWorkspaceService.delete(workspace.id)).resolves.toEqual({ + ok: true, + value: null, + }) + }) + + it('loads the active workspace id through the web API', async () => { + server.use( + http.get(apiUrl('/workspaces/active'), () => + HttpResponse.json({ workspaceId: workspace.id }), + ), + ) + + await expect(webWorkspaceService.getActiveId()).resolves.toEqual({ + ok: true, + value: workspace.id, + }) + }) + + it('loads a workspace by id through the web API', async () => { + server.use( + http.get(apiUrl('/workspaces/:workspaceId'), ({ params }) => { + expect(params.workspaceId).toBe(workspace.id) + + return HttpResponse.json(workspace) + }), + ) + + await expect(webWorkspaceService.getById(workspace.id)).resolves.toEqual({ + ok: true, + value: workspace, + }) + }) + + it('lists workspaces through the web API', async () => { + server.use( + http.get(apiUrl('/workspaces'), () => HttpResponse.json(listResult)), + ) + + await expect(webWorkspaceService.list()).resolves.toEqual({ + ok: true, + value: listResult, + }) + }) + + it('sets the active workspace through the web API', async () => { + server.use( + http.put(apiUrl('/workspaces/active'), async ({ request }) => { + expect(await request.json()).toEqual({ workspaceId: workspace.id }) + + return new HttpResponse(null, { status: 204 }) + }), + ) + + expectOk(await webWorkspaceService.setActiveId(workspace.id)) + }) + + it('updates workspaces through the web API', async () => { + server.use( + http.put(apiUrl('/workspaces/:workspaceId'), async ({ params, request }) => { + expect(params.workspaceId).toBe(workspace.id) + expect(await request.json()).toEqual(draft) + + return HttpResponse.json({ ...workspace, ...draft }) + }), + ) + + await expect(webWorkspaceService.update(workspace.id, draft)).resolves.toEqual({ + ok: true, + value: { ...workspace, ...draft }, + }) + }) +}) diff --git a/ui/src/test/web-api-msw.ts b/ui/src/test/web-api-msw.ts new file mode 100644 index 0000000..814158f --- /dev/null +++ b/ui/src/test/web-api-msw.ts @@ -0,0 +1,49 @@ +import { afterAll, afterEach, beforeAll } from 'vitest' +import { setupServer } from 'msw/node' + +import { client } from '@api-generated/clear-api/client.gen' +import type { Result } from '@shared/errors' + +export const WEB_API_BASE_URL = 'http://clear.test/api/v1' + +export const apiUrl = (path: `/${string}`) => `${WEB_API_BASE_URL}${path}` + +export const setupWebApiMsw = () => { + const server = setupServer() + const initialConfig = client.getConfig() + + beforeAll(() => { + client.setConfig({ + baseURL: WEB_API_BASE_URL, + throwOnError: true, + }) + server.listen({ onUnhandledRequest: 'error' }) + }) + + afterEach(() => { + server.resetHandlers() + }) + + afterAll(() => { + server.close() + client.setConfig(initialConfig) + }) + + return server +} + +export const expectOk = (result: Result) => { + if (!result.ok) { + throw new Error(`Expected ok result, received ${result.error.type}`) + } + + return result.value +} + +export const expectErr = (result: Result) => { + if (result.ok) { + throw new Error('Expected error result') + } + + return result.error +}