Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions frontend/cmmty/documents-page.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLAnchorElement>) => (
<a {...props}>{children}</a>
),
getPathname: () => "/admin/documents",
}));

function renderPage() {
return render(
<NextIntlClientProvider locale="en" messages={messages}>
<AdminDocumentsPage />
</NextIntlClientProvider>
);
}

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();
});
});
19 changes: 19 additions & 0 deletions frontend/cmmty/i18n-config.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
25 changes: 25 additions & 0 deletions frontend/cmmty/locale-routing.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
78 changes: 78 additions & 0 deletions frontend/cmmty/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
return HttpResponse.json({ ok: true, ...body });
}),
];
4 changes: 4 additions & 0 deletions frontend/cmmty/mocks/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);
6 changes: 5 additions & 1 deletion frontend/cmmty/test-setup.ts
Original file line number Diff line number Diff line change
@@ -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());
38 changes: 38 additions & 0 deletions frontend/cmmty/translations.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, 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<string, unknown>, fullKey));
} else {
keys.push(fullKey);
}
}
return keys.sort();
}

describe("translation parity", () => {
const enKeys = collectLeafKeys(en as Record<string, unknown>);
const frKeys = collectLeafKeys(fr as Record<string, unknown>);
const esKeys = collectLeafKeys(es as Record<string, unknown>);

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);
});
});
27 changes: 27 additions & 0 deletions frontend/e2e/example.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
8 changes: 8 additions & 0 deletions frontend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ const config = {
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/$1",
},
coverageThreshold: {
global: {
branches: 10,
functions: 10,
lines: 10,
statements: 10,
},
},
};

// next/jest sets a blanket `/node_modules/` transformIgnorePattern, which stops
Expand Down
6 changes: 5 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
32 changes: 32 additions & 0 deletions frontend/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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",
},
},
});
Loading