diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d91874..52dbab6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,8 +74,17 @@ jobs: env: NEXT_PUBLIC_API_URL: http://localhost:3001 + - name: Typecheck + run: npm run typecheck + - name: Unit tests - run: npm run test -- --passWithNoTests + run: npm run test + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: E2E tests + run: npm run test:e2e # ───────────────────────────────────────────── # SMART CONTRACTS — Rust / Cargo diff --git a/frontend/cmmty/documents-page.test.tsx b/frontend/cmmty/documents-page.test.tsx new file mode 100644 index 0000000..454f96a --- /dev/null +++ b/frontend/cmmty/documents-page.test.tsx @@ -0,0 +1,44 @@ +/** + * FE-69 — Reference MSW integration test. + * + * Renders the admin documents page and verifies that the component fetches + * data through the mocked API layer (no real HTTP calls are made). + */ + +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { NextIntlClientProvider } from "next-intl"; +import messages from "@/messages/en.json"; +import AdminDocumentsPage from "@/app/[locale]/(protected)/admin/documents/page"; + +jest.mock("@/i18n/navigation", () => ({ + useRouter: () => ({ push: jest.fn(), replace: jest.fn() }), + usePathname: () => "/admin/documents", + Link: ({ children, ...props }: React.AnchorHTMLAttributes) => ( + {children} + ), + getPathname: () => "/admin/documents", +})); + +function renderPage() { + return render( + + + + ); +} + +describe("AdminDocumentsPage (MSW)", () => { + it("loads and renders documents from the mocked API", async () => { + renderPage(); + + expect(screen.getByRole("status")).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText("Land Title Alpha")).toBeInTheDocument(); + }); + + expect(screen.getByText("Alice Smith")).toBeInTheDocument(); + expect(screen.getByText("Verified")).toBeInTheDocument(); + }); +}); diff --git a/frontend/cmmty/i18n-config.test.ts b/frontend/cmmty/i18n-config.test.ts new file mode 100644 index 0000000..bdb66b6 --- /dev/null +++ b/frontend/cmmty/i18n-config.test.ts @@ -0,0 +1,19 @@ +/** + * FE-70 — Tests for the i18n routing configuration. + */ + +import { routing } from "@/i18n/routing"; + +describe("i18n routing config", () => { + it("defines the expected locales", () => { + expect(routing.locales).toEqual(["en", "fr", "es"]); + }); + + it("sets the default locale to 'en'", () => { + expect(routing.defaultLocale).toBe("en"); + }); + + it("uses 'as-needed' locale prefix strategy", () => { + expect(routing.localePrefix).toBe("as-needed"); + }); +}); diff --git a/frontend/cmmty/locale-routing.test.tsx b/frontend/cmmty/locale-routing.test.tsx new file mode 100644 index 0000000..acb450b --- /dev/null +++ b/frontend/cmmty/locale-routing.test.tsx @@ -0,0 +1,25 @@ +/** + * FE-70 — Tests for locale resolution logic. + */ + +import { hasLocale } from "next-intl"; +import { routing } from "@/i18n/routing"; + +describe("locale resolution", () => { + it("accepts supported locales", () => { + expect(hasLocale(routing.locales, "en")).toBe(true); + expect(hasLocale(routing.locales, "fr")).toBe(true); + expect(hasLocale(routing.locales, "es")).toBe(true); + }); + + it("rejects unsupported locales", () => { + expect(hasLocale(routing.locales, "de")).toBe(false); + expect(hasLocale(routing.locales, "zh")).toBe(false); + expect(hasLocale(routing.locales, "pt")).toBe(false); + }); + + it("returns false for empty / undefined input", () => { + expect(hasLocale(routing.locales, undefined)).toBe(false); + expect(hasLocale(routing.locales, "")).toBe(false); + }); +}); diff --git a/frontend/cmmty/mocks/handlers.ts b/frontend/cmmty/mocks/handlers.ts new file mode 100644 index 0000000..5cde678 --- /dev/null +++ b/frontend/cmmty/mocks/handlers.ts @@ -0,0 +1,78 @@ +import { http, HttpResponse } from "msw"; + +const API_BASE = "http://localhost:3001"; + +export const handlers = [ + // Auth — login + http.post(`${API_BASE}/api/auth/login`, async ({ request }) => { + const body = (await request.json()) as { email: string; password: string }; + if (body.email === "bad@example.com") { + return HttpResponse.json( + { message: "Invalid credentials" }, + { status: 401 } + ); + } + return HttpResponse.json({ token: "fake-jwt-token" }); + }), + + // Auth — verify + http.get(`${API_BASE}/api/auth/verify`, ({ request }) => { + const auth = request.headers.get("Authorization"); + if (!auth || !auth.startsWith("Bearer ")) { + return HttpResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + return HttpResponse.json({ valid: true }); + }), + + // Documents — list + http.get(`${API_BASE}/api/admin/documents`, ({ request }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get("page") ?? "1"); + return HttpResponse.json({ + data: [ + { + id: "doc-1", + title: "Land Title Alpha", + status: "verified", + riskScore: 0.12, + riskFlags: [], + createdAt: "2025-06-01T10:00:00Z", + owner: { + id: "user-1", + email: "alice@example.com", + fullName: "Alice Smith", + }, + }, + ], + total: 1, + page, + pageSize: 20, + }); + }), + + // Documents — export PDF + http.get(`${API_BASE}/api/documents/:id/export/pdf`, () => { + return HttpResponse.arrayBuffer(new ArrayBuffer(0), { + headers: { "Content-Type": "application/pdf" }, + }); + }), + + // Users — me + http.get(`${API_BASE}/api/users/me`, ({ request }) => { + const auth = request.headers.get("Authorization"); + if (!auth) { + return HttpResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + return HttpResponse.json({ + id: "user-1", + email: "alice@example.com", + fullName: "Alice Smith", + preferredLanguage: "en", + }); + }), + + http.patch(`${API_BASE}/api/users/me`, async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json({ ok: true, ...body }); + }), +]; diff --git a/frontend/cmmty/mocks/server.ts b/frontend/cmmty/mocks/server.ts new file mode 100644 index 0000000..7b37f2a --- /dev/null +++ b/frontend/cmmty/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; + +export const server = setupServer(...handlers); diff --git a/frontend/cmmty/test-setup.ts b/frontend/cmmty/test-setup.ts index c0aa091..983feb7 100644 --- a/frontend/cmmty/test-setup.ts +++ b/frontend/cmmty/test-setup.ts @@ -1 +1,5 @@ -// Jest setup file for cmmty tests +import { server } from "./mocks/server"; + +beforeAll(() => server.listen({ onUnhandledRequest: "bypass" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/frontend/cmmty/translations.test.ts b/frontend/cmmty/translations.test.ts new file mode 100644 index 0000000..d7e5316 --- /dev/null +++ b/frontend/cmmty/translations.test.ts @@ -0,0 +1,38 @@ +/** + * FE-70 — Verify all locale JSON files have identical keys. + */ + +import en from "@/messages/en.json"; +import fr from "@/messages/fr.json"; +import es from "@/messages/es.json"; + +function collectLeafKeys(obj: Record, prefix = ""): string[] { + const keys: string[] = []; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + keys.push(...collectLeafKeys(value as Record, fullKey)); + } else { + keys.push(fullKey); + } + } + return keys.sort(); +} + +describe("translation parity", () => { + const enKeys = collectLeafKeys(en as Record); + const frKeys = collectLeafKeys(fr as Record); + const esKeys = collectLeafKeys(es as Record); + + it("en and fr have identical keys", () => { + expect(frKeys).toEqual(enKeys); + }); + + it("en and es have identical keys", () => { + expect(esKeys).toEqual(enKeys); + }); + + it("all locales have the same number of leaf keys", () => { + expect(new Set([enKeys.length, frKeys.length, esKeys.length]).size).toBe(1); + }); +}); diff --git a/frontend/e2e/example.spec.ts b/frontend/e2e/example.spec.ts new file mode 100644 index 0000000..2bc2608 --- /dev/null +++ b/frontend/e2e/example.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Smoke tests", () => { + test("homepage redirects to the default locale", async ({ page }) => { + const response = await page.goto("/"); + // next-intl redirects to /en when localePrefix is 'as-needed' for 'en' + expect(response?.url()).toContain("/en"); + }); + + test("login page renders the sign-in form", async ({ page }) => { + await page.goto("/en/login"); + await expect( + page.getByRole("heading", { name: /sign in/i }) + ).toBeVisible(); + await expect(page.getByLabel(/email/i)).toBeVisible(); + await expect(page.getByLabel(/password/i)).toBeVisible(); + await expect( + page.getByRole("button", { name: /sign in/i }) + ).toBeVisible(); + }); + + test("unsupported locale falls back to default", async ({ page }) => { + const response = await page.goto("/de/login"); + // Should redirect to the English version + expect(response?.url()).toContain("/en"); + }); +}); diff --git a/frontend/jest.config.js b/frontend/jest.config.js index b6ffa88..c76b924 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -10,6 +10,14 @@ const config = { moduleNameMapper: { "^@/(.*)$": "/$1", }, + coverageThreshold: { + global: { + branches: 10, + functions: 10, + lines: 10, + statements: 10, + }, + }, }; // next/jest sets a blanket `/node_modules/` transformIgnorePattern, which stops diff --git a/frontend/package.json b/frontend/package.json index fab7322..97a1bbb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,9 @@ "lint": "next lint", "test": "jest", "test:watch": "jest --watch", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test" }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -39,8 +41,10 @@ "@types/react-dom": "^19.1.9", "eslint": "^9", "eslint-config-next": "15.4.1", + "@playwright/test": "^1.52.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "msw": "^2.10.2", "tailwindcss": "^4", "tw-animate-css": "^1.3.5", "typescript": "^5" diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..2457e53 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "list", + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "mobile-chrome", + use: { ...devices["Pixel 5"] }, + }, + ], + webServer: { + command: "npm run dev", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + env: { + NEXT_PUBLIC_API_URL: "http://localhost:3001", + }, + }, +});