From d47d1a014a71ae65cbbf1b384eed87c6fe078b07 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 2 May 2026 11:58:13 +0900 Subject: feat(note-types): make note type CRUD work fully offline-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateNoteTypeModal, DeleteNoteTypeModal, and the NoteTypeEditor (which covers field add/edit/delete/reorder) now write through the local IndexedDB repositories and fire-and-forget syncActionAtom, mirroring the deck-CRUD pattern. The dead EditNoteTypeModal — never imported — is removed. The local hasNotes / hasNoteFieldValues guards mirror the server's delete-time checks so a note type with attached notes, or a field with saved values, can't be silently soft-deleted offline. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/client/pages/NoteTypesPage.test.tsx | 255 ++++++++++---------------------- 1 file changed, 80 insertions(+), 175 deletions(-) (limited to 'src/client/pages/NoteTypesPage.test.tsx') diff --git a/src/client/pages/NoteTypesPage.test.tsx b/src/client/pages/NoteTypesPage.test.tsx index 1a41185..137029f 100644 --- a/src/client/pages/NoteTypesPage.test.tsx +++ b/src/client/pages/NoteTypesPage.test.tsx @@ -10,7 +10,8 @@ import { queryClientAtom } from "jotai-tanstack-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; -import { authLoadingAtom, type NoteType } from "../atoms"; +import { authLoadingAtom, type NoteType, userAtom } from "../atoms"; +import { db } from "../db"; import { NoteTypesPage } from "./NoteTypesPage"; interface RenderOptions { @@ -18,13 +19,6 @@ interface RenderOptions { initialNoteTypes?: NoteType[]; } -const mockNoteTypesGet = vi.fn(); -const mockNoteTypesPost = vi.fn(); -const mockNoteTypeGet = vi.fn(); -const mockNoteTypePut = vi.fn(); -const mockNoteTypeDelete = vi.fn(); -const mockHandleResponse = vi.fn(); - vi.mock("../api/client", () => ({ apiClient: { login: vi.fn(), @@ -32,20 +26,7 @@ vi.mock("../api/client", () => ({ getTokens: vi.fn(), getAuthHeader: vi.fn(), onSessionExpired: vi.fn(() => vi.fn()), - rpc: { - api: { - "note-types": { - $get: () => mockNoteTypesGet(), - $post: (args: unknown) => mockNoteTypesPost(args), - ":id": { - $get: (args: unknown) => mockNoteTypeGet(args), - $put: (args: unknown) => mockNoteTypePut(args), - $delete: (args: unknown) => mockNoteTypeDelete(args), - }, - }, - }, - }, - handleResponse: (res: unknown) => mockHandleResponse(res), + rpc: { api: { "note-types": {} } }, }, ApiClientError: class ApiClientError extends Error { constructor( @@ -59,7 +40,6 @@ vi.mock("../api/client", () => ({ }, })); -// Mock queryClient module so pages use our test queryClient let testQueryClient: QueryClient; vi.mock("../queryClient", () => ({ get queryClient() { @@ -67,8 +47,6 @@ vi.mock("../queryClient", () => ({ }, })); -import { ApiClientError, apiClient } from "../api/client"; - const mockNoteTypes = [ { id: "note-type-1", @@ -90,6 +68,47 @@ const mockNoteTypes = [ }, ]; +async function seedNoteTypesInLocalDb( + noteTypes: NoteType[], + userId: string, + fields: { + noteTypeId: string; + id: string; + name: string; + order: number; + }[] = [], +) { + for (const nt of noteTypes) { + await db.noteTypes.put({ + id: nt.id, + userId, + name: nt.name, + frontTemplate: nt.frontTemplate, + backTemplate: nt.backTemplate, + isReversible: nt.isReversible, + createdAt: new Date(nt.createdAt), + updatedAt: new Date(nt.updatedAt), + deletedAt: null, + syncVersion: 0, + _synced: true, + }); + } + for (const f of fields) { + await db.noteFieldTypes.put({ + id: f.id, + noteTypeId: f.noteTypeId, + name: f.name, + order: f.order, + fieldType: "text", + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + syncVersion: 0, + _synced: true, + }); + } +} + function renderWithProviders({ path = "/note-types", initialNoteTypes, @@ -97,9 +116,9 @@ function renderWithProviders({ const { hook } = memoryLocation({ path }); const store = createStore(); store.set(authLoadingAtom, false); + store.set(userAtom, { id: "user-1", username: "alice" }); store.set(queryClientAtom, testQueryClient); - // Seed query cache if initial data provided if (initialNoteTypes !== undefined) { testQueryClient.setQueryData(["noteTypes"], initialNoteTypes); } @@ -114,38 +133,21 @@ function renderWithProviders({ } describe("NoteTypesPage", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + if (!db.isOpen()) { + await db.open(); + } + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); + testQueryClient = new QueryClient({ defaultOptions: { queries: { staleTime: Number.POSITIVE_INFINITY, retry: false }, }, }); - vi.mocked(apiClient.getTokens).mockReturnValue({ - accessToken: "access-token", - refreshToken: "refresh-token", - }); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); - - // handleResponse simulates actual behavior - // - If response is a plain object (from mocked RPC), pass through - // - If response is Response-like with ok/status, handle properly - mockHandleResponse.mockImplementation(async (res) => { - // Plain object (already the data) - pass through - if (res.ok === undefined && res.status === undefined) { - return res; - } - // Response-like object - if (!res.ok) { - const body = await res.json?.().catch(() => ({})); - throw new Error( - body?.error || `Request failed with status ${res.status}`, - ); - } - return typeof res.json === "function" ? res.json() : res; - }); }); afterEach(() => { @@ -161,15 +163,6 @@ describe("NoteTypesPage", () => { expect(screen.getByRole("link", { name: "Back to Home" })).toBeDefined(); }); - it("shows loading state while fetching note types", async () => { - mockNoteTypesGet.mockImplementation(() => new Promise(() => {})); // Never resolves - - renderWithProviders(); - - // Loading state shows spinner (svg with animate-spin class) - expect(document.querySelector(".animate-spin")).toBeDefined(); - }); - it("displays empty state when no note types exist", () => { renderWithProviders({ initialNoteTypes: [] }); @@ -207,44 +200,6 @@ describe("NoteTypesPage", () => { expect(screen.getAllByText("Back: {{Back}}").length).toBeGreaterThan(0); }); - // Skip: Error boundary tests don't work reliably with Jotai async atoms in test environment. - // Errors from rejected Promises in async atoms are not caught by ErrorBoundary in vitest. - it.skip("displays error on API failure", async () => { - mockNoteTypesGet.mockRejectedValue( - new ApiClientError("Internal server error", 500), - ); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Internal server error", - ); - }); - }); - - // Skip: Same reason as above - it.skip("displays generic error on unexpected failure", async () => { - mockNoteTypesGet.mockRejectedValue(new Error("Network error")); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain("Network error"); - }); - }); - - // Skip: Testing RPC endpoint calls is difficult with Suspense in test environment. - it.skip("calls correct RPC endpoint when fetching note types", async () => { - mockNoteTypesGet.mockResolvedValue({ noteTypes: [] }); - - renderWithProviders(); - - await waitFor(() => { - expect(mockNoteTypesGet).toHaveBeenCalled(); - }); - }); - describe("Create Note Type", () => { it("shows New Note Type button", () => { renderWithProviders({ initialNoteTypes: [] }); @@ -267,22 +222,8 @@ describe("NoteTypesPage", () => { ).toBeDefined(); }); - it("submits the new note type via the create endpoint", async () => { + it("creates a note type locally and closes the modal", async () => { const user = userEvent.setup(); - const newNoteType = { - id: "note-type-new", - name: "New Note Type", - frontTemplate: "{{Front}}", - backTemplate: "{{Back}}", - isReversible: false, - createdAt: "2024-01-03T00:00:00Z", - updatedAt: "2024-01-03T00:00:00Z", - }; - - mockNoteTypesPost.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: newNoteType }), - }); renderWithProviders({ initialNoteTypes: [] }); @@ -294,7 +235,11 @@ describe("NoteTypesPage", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); - expect(mockNoteTypesPost).toHaveBeenCalledTimes(1); + const persisted = await db.noteTypes + .filter((nt) => nt.name === "New Note Type") + .first(); + expect(persisted).toBeDefined(); + expect(persisted?._synced).toBe(false); }); }); @@ -310,30 +255,18 @@ describe("NoteTypesPage", () => { expect(editButtons.length).toBe(2); }); - it("opens edit modal when Edit button is clicked", async () => { + it("opens edit modal and loads the note type from local DB", async () => { const user = userEvent.setup(); - const mockNoteTypeWithFields = { - ...mockNoteTypes[0], - fields: [ - { - id: "field-1", - noteTypeId: "note-type-1", - name: "Front", - order: 0, - fieldType: "text", - }, - { - id: "field-2", - noteTypeId: "note-type-1", - name: "Back", - order: 1, - fieldType: "text", - }, - ], - }; - - mockNoteTypeGet.mockResolvedValue({ noteType: mockNoteTypeWithFields }); + await seedNoteTypesInLocalDb(mockNoteTypes, "user-1", [ + { + id: "field-1", + noteTypeId: "note-type-1", + name: "Front", + order: 0, + }, + { id: "field-2", noteTypeId: "note-type-1", name: "Back", order: 1 }, + ]); renderWithProviders({ initialNoteTypes: mockNoteTypes }); const editButtons = screen.getAllByRole("button", { @@ -351,38 +284,10 @@ describe("NoteTypesPage", () => { }); }); - it("submits the edited note type via the update endpoint", async () => { + it("updates the note type locally and closes the editor", async () => { const user = userEvent.setup(); - const mockNoteTypeWithFields = { - ...mockNoteTypes[0], - fields: [ - { - id: "field-1", - noteTypeId: "note-type-1", - name: "Front", - order: 0, - fieldType: "text", - }, - { - id: "field-2", - noteTypeId: "note-type-1", - name: "Back", - order: 1, - fieldType: "text", - }, - ], - }; - const updatedNoteType = { - ...mockNoteTypes[0], - name: "Updated Basic", - }; - - mockNoteTypeGet.mockResolvedValue({ noteType: mockNoteTypeWithFields }); - mockNoteTypePut.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: updatedNoteType }), - }); + await seedNoteTypesInLocalDb(mockNoteTypes, "user-1"); renderWithProviders({ initialNoteTypes: mockNoteTypes }); const editButtons = screen.getAllByRole("button", { @@ -404,7 +309,9 @@ describe("NoteTypesPage", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); - expect(mockNoteTypePut).toHaveBeenCalledTimes(1); + const persisted = await db.noteTypes.get("note-type-1"); + expect(persisted?.name).toBe("Updated Basic"); + expect(persisted?._synced).toBe(false); }); }); @@ -437,14 +344,10 @@ describe("NoteTypesPage", () => { expect(dialog.textContent).toContain("Basic"); }); - it("submits the note type delete via the delete endpoint", async () => { + it("soft-deletes the note type locally and closes the modal", async () => { const user = userEvent.setup(); - mockNoteTypeDelete.mockResolvedValue({ - ok: true, - json: async () => ({ success: true }), - }); - + await seedNoteTypesInLocalDb(mockNoteTypes, "user-1"); renderWithProviders({ initialNoteTypes: mockNoteTypes }); const deleteButtons = screen.getAllByRole("button", { @@ -467,7 +370,9 @@ describe("NoteTypesPage", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); - expect(mockNoteTypeDelete).toHaveBeenCalledTimes(1); + const persisted = await db.noteTypes.get("note-type-1"); + expect(persisted?.deletedAt).not.toBeNull(); + expect(persisted?._synced).toBe(false); }); }); }); -- cgit v1.3.1