From d47d1a014a71ae65cbbf1b384eed87c6fe078b07 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
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) <noreply@anthropic.com>
---
 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

