aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/components/EditDeckModal.test.tsx
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-02 11:46:13 +0900
committernsfisis <nsfisis@gmail.com>2026-05-02 11:46:13 +0900
commit023d0fcfce575030ee503c5f60df8c28dba7ab07 (patch)
tree2f8ab3915f338232619ec66bb272e4756a96e021 /src/client/components/EditDeckModal.test.tsx
parent13a3d16ffc88845d7bc65fb0778da9aaff53b653 (diff)
downloadkioku-023d0fcfce575030ee503c5f60df8c28dba7ab07.tar.gz
kioku-023d0fcfce575030ee503c5f60df8c28dba7ab07.tar.zst
kioku-023d0fcfce575030ee503c5f60df8c28dba7ab07.zip
feat(decks): make deck CRUD work fully offline-first
Create / Edit / Delete deck modals now write through localDeckRepository and fire-and-forget syncActionAtom so the change is pushed when the network is up. EditDeckModal reads its note-type list from the local-first noteTypesAtom instead of fetching, and the "reconnect to..." guards on the submit buttons are gone — the user can keep working while offline. Soft-delete intentionally does NOT cascade to notes/cards, matching the server's existing deck.softDelete: the deck disappears from listings and its children become unreachable that way. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/client/components/EditDeckModal.test.tsx')
-rw-r--r--src/client/components/EditDeckModal.test.tsx222
1 files changed, 51 insertions, 171 deletions
diff --git a/src/client/components/EditDeckModal.test.tsx b/src/client/components/EditDeckModal.test.tsx
index 248c74f..15a30a1 100644
--- a/src/client/components/EditDeckModal.test.tsx
+++ b/src/client/components/EditDeckModal.test.tsx
@@ -3,43 +3,23 @@
*/
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import { atom } from "jotai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-const mockPut = vi.fn();
-const mockHandleResponse = vi.fn();
+const mockUpdate = vi.fn();
+const mockTriggerSync = vi.fn(() => Promise.resolve(null));
-const mockGetNoteTypes = vi.fn();
-
-vi.mock("../api/client", () => ({
- apiClient: {
- rpc: {
- api: {
- decks: {
- ":id": {
- $put: (args: unknown) => mockPut(args),
- },
- },
- "note-types": {
- $get: () => mockGetNoteTypes(),
- },
- },
- },
- handleResponse: (res: unknown) => mockHandleResponse(res),
- },
- ApiClientError: class ApiClientError extends Error {
- constructor(
- message: string,
- public status: number,
- public code?: string,
- ) {
- super(message);
- this.name = "ApiClientError";
- }
+vi.mock("../db/repositories", () => ({
+ localDeckRepository: {
+ update: (...args: unknown[]) => mockUpdate(...args),
},
}));
-import { ApiClientError } from "../api/client";
-// Import after mock is set up
+vi.mock("../atoms", () => ({
+ syncActionAtom: atom(null, () => mockTriggerSync()),
+ noteTypesAtom: atom({ data: [] as { id: string; name: string }[] }),
+}));
+
import { EditDeckModal } from "./EditDeckModal";
describe("EditDeckModal", () => {
@@ -57,25 +37,14 @@ describe("EditDeckModal", () => {
onDeckUpdated: vi.fn(),
};
- const noteTypesResponse = { ok: true, _type: "noteTypes" };
- const putResponse = { ok: true, _type: "put" };
-
beforeEach(() => {
vi.clearAllMocks();
- mockPut.mockResolvedValue(putResponse);
- mockGetNoteTypes.mockResolvedValue(noteTypesResponse);
- mockHandleResponse.mockImplementation((res: unknown) => {
- if (res === noteTypesResponse) {
- return Promise.resolve({ noteTypes: [] });
- }
- return Promise.resolve({
- deck: {
- id: "deck-123",
- name: "Test Deck",
- description: "Test description",
- defaultNoteTypeId: null,
- },
- });
+ mockUpdate.mockResolvedValue({
+ id: "deck-123",
+ userId: "user-1",
+ name: "Test Deck",
+ description: "Test description",
+ defaultNoteTypeId: null,
});
});
@@ -155,30 +124,7 @@ describe("EditDeckModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
- it("calls onClose when clicking outside the modal", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- render(<EditDeckModal {...defaultProps} onClose={onClose} />);
-
- // Click on the backdrop (the dialog element itself)
- const dialog = screen.getByRole("dialog");
- await user.click(dialog);
-
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("does not call onClose when clicking inside the modal content", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- render(<EditDeckModal {...defaultProps} onClose={onClose} />);
-
- // Click on an element inside the modal
- await user.click(screen.getByLabelText("Name"));
-
- expect(onClose).not.toHaveBeenCalled();
- });
-
- it("updates deck with new name", async () => {
+ it("updates deck via local repository with new name", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const onDeckUpdated = vi.fn();
@@ -198,47 +144,10 @@ describe("EditDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockPut).toHaveBeenCalledWith({
- param: { id: "deck-123" },
- json: {
- name: "Updated Deck",
- description: "Test description",
- defaultNoteTypeId: null,
- },
- });
- });
-
- expect(onDeckUpdated).toHaveBeenCalledTimes(1);
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("updates deck with new description", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- const onDeckUpdated = vi.fn();
-
- render(
- <EditDeckModal
- isOpen={true}
- deck={mockDeck}
- onClose={onClose}
- onDeckUpdated={onDeckUpdated}
- />,
- );
-
- const descInput = screen.getByLabelText("Description (optional)");
- await user.clear(descInput);
- await user.type(descInput, "New description");
- await user.click(screen.getByRole("button", { name: "Save Changes" }));
-
- await waitFor(() => {
- expect(mockPut).toHaveBeenCalledWith({
- param: { id: "deck-123" },
- json: {
- name: "Test Deck",
- description: "New description",
- defaultNoteTypeId: null,
- },
+ expect(mockUpdate).toHaveBeenCalledWith("deck-123", {
+ name: "Updated Deck",
+ description: "Test description",
+ defaultNoteTypeId: null,
});
});
@@ -256,13 +165,10 @@ describe("EditDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockPut).toHaveBeenCalledWith({
- param: { id: "deck-123" },
- json: {
- name: "Test Deck",
- description: null,
- defaultNoteTypeId: null,
- },
+ expect(mockUpdate).toHaveBeenCalledWith("deck-123", {
+ name: "Test Deck",
+ description: null,
+ defaultNoteTypeId: null,
});
});
});
@@ -280,21 +186,30 @@ describe("EditDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockPut).toHaveBeenCalledWith({
- param: { id: "deck-123" },
- json: {
- name: "Deck",
- description: "Description",
- defaultNoteTypeId: null,
- },
+ expect(mockUpdate).toHaveBeenCalledWith("deck-123", {
+ name: "Deck",
+ description: "Description",
+ defaultNoteTypeId: null,
});
});
});
+ it("triggers a background sync after a successful update", async () => {
+ const user = userEvent.setup();
+
+ render(<EditDeckModal {...defaultProps} />);
+
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
+
+ await waitFor(() => {
+ expect(mockTriggerSync).toHaveBeenCalled();
+ });
+ });
+
it("shows loading state during submission", async () => {
const user = userEvent.setup();
- mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves
+ mockUpdate.mockImplementation(() => new Promise(() => {}));
render(<EditDeckModal {...defaultProps} />);
@@ -316,32 +231,26 @@ describe("EditDeckModal", () => {
);
});
- it("displays API error message", async () => {
+ it("shows an error when the deck no longer exists locally", async () => {
const user = userEvent.setup();
- render(<EditDeckModal {...defaultProps} />);
+ mockUpdate.mockResolvedValue(undefined);
- // Wait for note types to load, then override handleResponse for the PUT
- await waitFor(() => {
- expect(mockGetNoteTypes).toHaveBeenCalled();
- });
- mockHandleResponse.mockRejectedValue(
- new ApiClientError("Deck name already exists", 400),
- );
+ render(<EditDeckModal {...defaultProps} />);
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
- "Deck name already exists",
+ "Deck not found.",
);
});
});
- it("displays generic error on unexpected failure", async () => {
+ it("displays a generic error when the local write fails", async () => {
const user = userEvent.setup();
- mockPut.mockRejectedValue(new Error("Network error"));
+ mockUpdate.mockRejectedValue(new Error("disk full"));
render(<EditDeckModal {...defaultProps} />);
@@ -354,28 +263,6 @@ describe("EditDeckModal", () => {
});
});
- it("displays error when handleResponse throws", async () => {
- const user = userEvent.setup();
-
- render(<EditDeckModal {...defaultProps} />);
-
- // Wait for note types to load, then override handleResponse for the PUT
- await waitFor(() => {
- expect(mockGetNoteTypes).toHaveBeenCalled();
- });
- mockHandleResponse.mockRejectedValue(
- new ApiClientError("Not authenticated", 401),
- );
-
- await user.click(screen.getByRole("button", { name: "Save Changes" }));
-
- await waitFor(() => {
- expect(screen.getByRole("alert").textContent).toContain(
- "Not authenticated",
- );
- });
- });
-
it("updates form when deck prop changes", () => {
const { rerender } = render(<EditDeckModal {...defaultProps} />);
@@ -402,27 +289,20 @@ describe("EditDeckModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
+ mockUpdate.mockRejectedValueOnce(new Error("Some error"));
+
const { rerender } = render(
<EditDeckModal {...defaultProps} onClose={onClose} />,
);
- // Wait for note types to load, then override handleResponse for the PUT
- await waitFor(() => {
- expect(mockGetNoteTypes).toHaveBeenCalled();
- });
- mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 400));
-
- // Trigger error
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert")).toBeDefined();
});
- // Close and reopen the modal
await user.click(screen.getByRole("button", { name: "Cancel" }));
rerender(<EditDeckModal {...defaultProps} onClose={onClose} />);
- // Error should be cleared
expect(screen.queryByRole("alert")).toBeNull();
});
});