diff --git a/frontend/src/api/apiClient.test.ts b/frontend/src/api/apiClient.test.ts
index 074bc8e..a9898ec 100644
--- a/frontend/src/api/apiClient.test.ts
+++ b/frontend/src/api/apiClient.test.ts
@@ -1,13 +1,5 @@
import { HttpResponse, http } from "msw";
-import {
- afterAll,
- beforeAll,
- beforeEach,
- describe,
- expect,
- it,
- vi,
-} from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mockUser } from "../../test/fixtures/user.fixture";
import { server } from "../../test/mocks/server";
import { useAuthStore } from "../store/useAuthStore";
@@ -21,13 +13,10 @@ beforeEach(() => {
user: null,
isInitializing: false,
});
-});
-
-beforeAll(() => {
vi.stubEnv("VITE_API_URL", VITE_API_URL);
});
-afterAll(() => {
+afterEach(() => {
vi.unstubAllEnvs();
});
diff --git a/frontend/src/hooks/useAuth.test.ts b/frontend/src/hooks/useAuth.test.ts
index b397d3b..c6d0f43 100644
--- a/frontend/src/hooks/useAuth.test.ts
+++ b/frontend/src/hooks/useAuth.test.ts
@@ -6,7 +6,6 @@ import { mockUser } from "../../test/fixtures/user.fixture";
import { server } from "../../test/mocks/server";
import { useAuthStore } from "../store/useAuthStore";
import { useKeyStore } from "../store/useKeyStore";
-import { CryptoUtils } from "../utils/crypto";
import {
clearMasterKey,
loadMasterKey,
@@ -14,7 +13,6 @@ import {
} from "../utils/keystore";
import { useAuth } from "./useAuth";
-vi.mock("../utils/crypto");
vi.mock("../utils/keystore");
const VITE_API_URL = "http://piku-server";
@@ -22,12 +20,6 @@ const VITE_API_URL = "http://piku-server";
beforeEach(() => {
vi.clearAllMocks();
- // hack to set up mock implementations using fixtures
- vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({
- masterKey: mockMasterKey,
- authHash: "mock-auth-hash",
- });
-
vi.mocked(loadMasterKey).mockResolvedValue(mockMasterKey);
vi.mocked(saveMasterKey).mockResolvedValue("masterKey");
vi.mocked(clearMasterKey).mockResolvedValue(undefined);
diff --git a/frontend/src/hooks/useLetters.test.ts b/frontend/src/hooks/useLetters.test.ts
new file mode 100644
index 0000000..83a3d31
--- /dev/null
+++ b/frontend/src/hooks/useLetters.test.ts
@@ -0,0 +1,113 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+import { beforeEach, describe, expect, it } from "vitest";
+import { server } from "../../test/mocks/server";
+import { endpoints } from "../config/endpoints";
+import { useKeyStore } from "../store/useKeyStore";
+import { CryptoUtils } from "../utils/crypto";
+import { useLetters } from "./useLetters";
+
+describe("useLetters hook", () => {
+ let masterKey: CryptoKey;
+ let utils: CryptoUtils;
+
+ beforeEach(async () => {
+ utils = new CryptoUtils();
+ await utils.initialize();
+ const bundle = await CryptoUtils.deriveKeyBundle("password", "salt");
+ masterKey = bundle.masterKey;
+
+ useKeyStore.setState({ masterKey: null });
+ });
+
+ it("should indicate authentication is required when masterKey is missing", () => {
+ const { result } = renderHook(() => useLetters());
+
+ expect(result.current.isAuthRequired).toBe(true);
+ });
+
+ it("should fetch, decrypt, and categorize letters when masterKey is present", async () => {
+ useKeyStore.setState({ masterKey });
+
+ const draftPayload = { objects: [] };
+ const encryptedDraft = await utils.encryptMetadata(
+ { recipient: "Draft Recipient" },
+ masterKey,
+ );
+
+ const lettersResponse = [
+ {
+ public_id: "letter-1",
+ type: "KEPT",
+ status: "DRAFT",
+ updated_at: new Date().toISOString(),
+ encrypted_metadata: encryptedDraft.encrypted_content,
+ encrypted_content: JSON.stringify(draftPayload),
+ encrypted_dek: encryptedDraft.encrypted_dek,
+ },
+ ];
+
+ server.use(
+ http.get(`${import.meta.env.VITE_API_URL}${endpoints.LETTERS}`, () => {
+ return HttpResponse.json(lettersResponse);
+ }),
+ );
+
+ const { result } = renderHook(() => useLetters());
+
+ // Initially loading
+ expect(result.current.loading).toBe(true);
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(result.current.drafts).toHaveLength(1);
+ expect(result.current.drafts[0].metadata.recipient).toBe("Draft Recipient");
+ expect(result.current.kept).toHaveLength(0);
+ });
+
+ it("should sort letters by updated_at in descending order", async () => {
+ useKeyStore.setState({ masterKey });
+
+ const metadata = await utils.encryptMetadata(
+ { recipient: "test" },
+ masterKey,
+ );
+
+ const now = new Date();
+ const older = new Date(now.getTime() - 10000);
+
+ const lettersResponse = [
+ {
+ public_id: "older",
+ type: "KEPT",
+ status: "SEALED",
+ updated_at: older.toISOString(),
+ encrypted_metadata: metadata.encrypted_content,
+ encrypted_content: "{}",
+ encrypted_dek: metadata.encrypted_dek,
+ },
+ {
+ public_id: "newer",
+ type: "KEPT",
+ status: "SEALED",
+ updated_at: now.toISOString(),
+ encrypted_metadata: metadata.encrypted_content,
+ encrypted_content: "{}",
+ encrypted_dek: metadata.encrypted_dek,
+ },
+ ];
+
+ server.use(
+ http.get(`${import.meta.env.VITE_API_URL}${endpoints.LETTERS}`, () => {
+ return HttpResponse.json(lettersResponse);
+ }),
+ );
+
+ const { result } = renderHook(() => useLetters());
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(result.current.kept[0].public_id).toBe("newer");
+ expect(result.current.kept[1].public_id).toBe("older");
+ });
+});
diff --git a/frontend/src/pages/Drawer.test.tsx b/frontend/src/pages/Drawer.test.tsx
index 414c9e3..e8657c6 100644
--- a/frontend/src/pages/Drawer.test.tsx
+++ b/frontend/src/pages/Drawer.test.tsx
@@ -1,10 +1,13 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
-import { beforeEach, describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { mockUser } from "../../test/fixtures/user.fixture";
+import { useLetters } from "../hooks/useLetters";
import { useAuthStore } from "../store/useAuthStore";
import Drawer from "./Drawer";
+vi.mock("../hooks/useLetters");
+
describe("Drawer Page", () => {
beforeEach(() => {
// Setup authenticated state for the test
@@ -13,6 +16,15 @@ describe("Drawer Page", () => {
accessToken: "fake-token",
isInitializing: false,
});
+
+ vi.mocked(useLetters).mockReturnValue({
+ drafts: [],
+ kept: [],
+ sent: [],
+ vault: [],
+ loading: false,
+ isAuthRequired: false,
+ });
});
it("renders the cabinet sections and empty state message", () => {
@@ -27,4 +39,43 @@ describe("Drawer Page", () => {
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
});
+
+ it("renders the loading state", () => {
+ vi.mocked(useLetters).mockReturnValue({
+ drafts: [],
+ kept: [],
+ sent: [],
+ vault: [],
+ loading: true,
+ isAuthRequired: false,
+ });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument();
+ });
+
+ it("renders the authentication required modal when api requires auth", () => {
+ vi.mocked(useLetters).mockReturnValue({
+ drafts: [],
+ kept: [],
+ sent: [],
+ vault: [],
+ loading: false,
+ isAuthRequired: true,
+ });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument();
+ expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
+ });
});
diff --git a/frontend/src/utils/crypto.test.ts b/frontend/src/utils/crypto.test.ts
index bcd282f..549d8bd 100644
--- a/frontend/src/utils/crypto.test.ts
+++ b/frontend/src/utils/crypto.test.ts
@@ -169,7 +169,7 @@ describe("encryptImage / decryptImage", () => {
});
});
-describe("Sharing Key Decryption (TDD)", () => {
+describe("Sharing Key Decryption", () => {
let masterKey: CryptoKey;
beforeEach(async () => {
const bundle = await CryptoUtils.deriveKeyBundle("password", "salt");