diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8730bedd..669de1f9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -93,29 +93,25 @@ jobs:
- name: Typecheck
run: npm run typecheck
- - name: Unit tests
- run: npm run test
+ - name: Unit tests with coverage
+ run: npm run test:coverage
+
+ - name: Upload frontend coverage
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: frontend-coverage
+ path: frontend/coverage/
- name: Accessibility tests (jest-axe)
run: npm run test:a11y -- --passWithNoTests
- name: Install Playwright browsers
- run: npx playwright install --with-deps chromium
+ run: npx playwright install --with-deps chromium firefox webkit
- name: E2E tests
run: npm run test:e2e
- # TODO: Remove --passWithNoTests and switch to test:coverage once real test suites exist
- # - name: Unit tests with coverage
- # run: npm run test:coverage
- #
- # - name: Upload coverage artifact
- # if: always()
- # uses: actions/upload-artifact@v4
- # with:
- # name: frontend-coverage
- # path: frontend/coverage/
-
# ─────────────────────────────────────────────
# SMART CONTRACTS — Rust / Cargo
# ─────────────────────────────────────────────
diff --git a/backend/nest-cli.json b/backend/nest-cli.json
index f9aa683b..c913ca36 100644
--- a/backend/nest-cli.json
+++ b/backend/nest-cli.json
@@ -3,6 +3,12 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
- "deleteOutDir": true
+ "deleteOutDir": true,
+ "assets": [
+ {
+ "include": "contracts/**/*.json",
+ "watchAssets": true
+ }
+ ]
}
}
diff --git a/backend/package.json b/backend/package.json
index 441807b3..99a8d75b 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -6,11 +6,11 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
- "build": "nest build",
+ "build": "node ../contracts/sync.mjs && nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
- "start:dev": "nest start --watch",
- "start:debug": "nest start --debug --watch",
+ "start:dev": "node ../contracts/sync.mjs && nest start --watch",
+ "start:debug": "node ../contracts/sync.mjs && nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
diff --git a/backend/src/auth/dto/login-auth.dto.ts b/backend/src/auth/dto/login-auth.dto.ts
index 61f6a996..76d96726 100644
--- a/backend/src/auth/dto/login-auth.dto.ts
+++ b/backend/src/auth/dto/login-auth.dto.ts
@@ -1,9 +1,11 @@
-import { IsEmail, IsNotEmpty } from 'class-validator';
+import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
+import { apiContracts } from '../../common/api-contracts';
export class LoginAuthDto {
@IsEmail()
email: string;
@IsNotEmpty()
+ @MinLength(apiContracts.login.password.minLength)
password: string;
}
diff --git a/backend/src/auth/dto/register-auth.dto.ts b/backend/src/auth/dto/register-auth.dto.ts
index 754fdc60..5aa08185 100644
--- a/backend/src/auth/dto/register-auth.dto.ts
+++ b/backend/src/auth/dto/register-auth.dto.ts
@@ -6,6 +6,7 @@
MinLength,
} from 'class-validator';
import { Transform } from 'class-transformer';
+import { apiContracts } from '../../common/api-contracts';
export class RegisterAuthDto {
@IsEmail()
@@ -13,7 +14,7 @@ export class RegisterAuthDto {
email: string;
@IsString()
- @MinLength(8)
+ @MinLength(apiContracts.register.password.minLength)
@Matches(/[A-Z]/, { message: 'passwordUppercase' })
@Matches(/[a-z]/, { message: 'passwordLowercase' })
@Matches(/[0-9]/, { message: 'passwordNumber' })
@@ -21,6 +22,7 @@ export class RegisterAuthDto {
password: string;
@IsNotEmpty()
+ @MinLength(apiContracts.register.fullName.minLength)
@Transform(({ value }) => value?.trim())
fullName: string;
}
diff --git a/backend/src/common/api-contracts.ts b/backend/src/common/api-contracts.ts
new file mode 100644
index 00000000..243345d2
--- /dev/null
+++ b/backend/src/common/api-contracts.ts
@@ -0,0 +1,48 @@
+type StringContract = {
+ format?: 'email' | 'uuid';
+ minLength?: number;
+};
+
+type ApiContracts = {
+ api: {
+ version: string;
+ };
+ register: {
+ email: StringContract;
+ password: { minLength: number };
+ fullName: { minLength: number };
+ };
+ login: {
+ email: StringContract;
+ password: { minLength: number };
+ };
+ dispute: {
+ documentId: StringContract;
+ description: { minLength: number };
+ statuses: string[];
+ };
+ documentUpload: {
+ fileSize: { max: number };
+ mimeTypes: string[];
+ };
+};
+
+export const apiContracts = require('../contracts/api-contracts.json') as ApiContracts;
+
+const contractFormats: Array<[string | undefined, string]> = [
+ [apiContracts.register.email.format, 'email'],
+ [apiContracts.login.email.format, 'email'],
+ [apiContracts.dispute.documentId.format, 'uuid'],
+];
+for (const [format, expected] of contractFormats) {
+ if (format !== expected) {
+ throw new Error(`Unsupported API contract format: ${format}`);
+ }
+}
+
+export const DISPUTE_STATUS_VALUES = apiContracts.dispute.statuses;
+
+export const DOCUMENT_MAX_FILE_SIZE_BYTES =
+ apiContracts.documentUpload.fileSize.max;
+export const DOCUMENT_ALLOWED_MIME_TYPES =
+ apiContracts.documentUpload.mimeTypes;
diff --git a/backend/src/contracts/api-contracts.json b/backend/src/contracts/api-contracts.json
new file mode 100644
index 00000000..66ea7b4e
--- /dev/null
+++ b/backend/src/contracts/api-contracts.json
@@ -0,0 +1,48 @@
+{
+ "api": {
+ "version": "1"
+ },
+ "register": {
+ "email": {
+ "format": "email"
+ },
+ "password": {
+ "minLength": 8
+ },
+ "fullName": {
+ "minLength": 2
+ }
+ },
+ "login": {
+ "email": {
+ "format": "email"
+ },
+ "password": {
+ "minLength": 1
+ }
+ },
+ "dispute": {
+ "documentId": {
+ "format": "uuid"
+ },
+ "description": {
+ "minLength": 1
+ },
+ "statuses": [
+ "open",
+ "in_review",
+ "resolved",
+ "dismissed"
+ ]
+ },
+ "documentUpload": {
+ "fileSize": {
+ "max": 20971520
+ },
+ "mimeTypes": [
+ "application/pdf",
+ "image/png",
+ "image/jpeg"
+ ]
+ }
+}
diff --git a/backend/src/dispute/dispute-status.state-machine.ts b/backend/src/dispute/dispute-status.state-machine.ts
index 6011a283..f14dce9f 100644
--- a/backend/src/dispute/dispute-status.state-machine.ts
+++ b/backend/src/dispute/dispute-status.state-machine.ts
@@ -1,16 +1,8 @@
-/**
- * Explicit state machine guarding dispute status transitions, preventing
- * arbitrary moves such as resolved -> open with no guard rails.
- */
-export type DisputeStatus = 'open' | 'under_review' | 'resolved' | 'closed';
-
-const ALLOWED_TRANSITIONS: Record
- Classified as: {dispute.reason} + Classified as: {dispute.reason.name}
)} @@ -231,7 +232,7 @@ export default function DisputeDetailPage() { - {(dispute.status === "RESOLVED" || dispute.status === "REJECTED") && + {(dispute.status === "resolved" || dispute.status === "dismissed") && dispute.resolution && (- Upload PDF, PNG, or JPEG documents (up to 20MB) for automated AI verification. + Upload PDF, PNG, or JPEG documents (up to {MAX_SIZE_MB}MB) for + automated AI verification.
{error}
} diff --git a/frontend/contracts/api-contracts.json b/frontend/contracts/api-contracts.json new file mode 100644 index 00000000..66ea7b4e --- /dev/null +++ b/frontend/contracts/api-contracts.json @@ -0,0 +1,48 @@ +{ + "api": { + "version": "1" + }, + "register": { + "email": { + "format": "email" + }, + "password": { + "minLength": 8 + }, + "fullName": { + "minLength": 2 + } + }, + "login": { + "email": { + "format": "email" + }, + "password": { + "minLength": 1 + } + }, + "dispute": { + "documentId": { + "format": "uuid" + }, + "description": { + "minLength": 1 + }, + "statuses": [ + "open", + "in_review", + "resolved", + "dismissed" + ] + }, + "documentUpload": { + "fileSize": { + "max": 20971520 + }, + "mimeTypes": [ + "application/pdf", + "image/png", + "image/jpeg" + ] + } +} diff --git a/frontend/e2e/dispute.spec.ts b/frontend/e2e/dispute.spec.ts new file mode 100644 index 00000000..9e3f4746 --- /dev/null +++ b/frontend/e2e/dispute.spec.ts @@ -0,0 +1,175 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; +import { API_PREFIX } from "../lib/api-contracts"; +import type { + DisputeListResponse, + DisputeResponse, +} from "../lib/schemas/dispute"; +import type { DocumentListResponse } from "../lib/schemas/document"; + +const API_BASE = "http://localhost:3001"; +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "http://localhost:3000", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS", +}; +const ACCESS_TOKEN = "e2e-access-token"; +const USER_ID = "33333333-3333-4333-8333-333333333333"; +const DOCUMENT_ID = "11111111-1111-4111-8111-111111111111"; +const DESCRIPTION = + "The parcel boundary conflicts with the registered survey."; + +type DisputePayload = { + documentId: string; + description: string; +}; + +type FiledRequest = { + payload: DisputePayload; + authorization: string | null; +}; + +const emptyDisputes: DisputeListResponse = { data: [], total: 0 }; +const documents: DocumentListResponse = { + data: [ + { + id: DOCUMENT_ID, + title: "Land Deed", + status: "pending", + riskScore: null, + riskFlags: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + total: 1, + page: 1, + limit: 20, +}; +const filedDispute: DisputeResponse = { + id: "22222222-2222-4222-8222-222222222222", + documentId: DOCUMENT_ID, + description: DESCRIPTION, + reason: { + id: "44444444-4444-4444-8444-444444444444", + name: "Boundary mismatch", + }, + status: "open", + filedBy: USER_ID, + createdAt: "2026-01-02T00:00:00.000Z", +}; + +async function authenticate(page: Page) { + await page.addInitScript((token) => { + window.localStorage.setItem("auth-token", token); + }, ACCESS_TOKEN); +} + +async function fulfillJson(route: Route, status: number, body: unknown) { + await route.fulfill({ + status, + headers: CORS_HEADERS, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +async function mockDisputeApi(page: Page) { + let filedRequest: FiledRequest | null = null; + + await page.route(`${API_BASE}/api/**`, async (route) => { + const request = route.request(); + const { pathname } = new URL(request.url()); + + if (request.method() === "OPTIONS") { + await route.fulfill({ status: 204, headers: CORS_HEADERS }); + return; + } + + if ( + request.method() === "GET" && + pathname === `${API_PREFIX}/disputes` + ) { + await fulfillJson(route, 200, emptyDisputes); + return; + } + + if ( + request.method() === "GET" && + pathname === `${API_PREFIX}/documents` + ) { + await fulfillJson(route, 200, documents); + return; + } + + if ( + request.method() === "GET" && + pathname === "/api/notifications" + ) { + await fulfillJson(route, 200, []); + return; + } + + if ( + request.method() === "GET" && + pathname === "/api/notifications/unread-count" + ) { + await fulfillJson(route, 200, { count: 0 }); + return; + } + + if ( + request.method() === "POST" && + pathname === `${API_PREFIX}/disputes` + ) { + filedRequest = { + payload: request.postDataJSON() as DisputePayload, + authorization: request.headers()["authorization"] ?? null, + }; + await fulfillJson(route, 201, filedDispute); + return; + } + + await fulfillJson(route, 404, { message: "Unmocked API request" }); + }); + + return () => filedRequest; +} + +test.describe("Dispute filing", () => { + test("files a dispute through the FileDisputeModal", async ({ page }) => { + await authenticate(page); + const getFiledRequest = await mockDisputeApi(page); + + await page.goto("/en/disputes"); + await expect( + page.getByRole("heading", { name: "My Disputes" }) + ).toBeVisible(); + + await page.getByRole("button", { name: "File a new dispute" }).click(); + + const modalHeading = page.getByRole("heading", { + name: "File a New Dispute", + }); + await expect(modalHeading).toBeVisible(); + await expect( + page.getByRole("option", { name: "Land Deed" }) + ).toBeAttached(); + await page.getByLabel("Select Document").selectOption(DOCUMENT_ID); + await page.getByLabel("Reason for Dispute").fill(DESCRIPTION); + await page.getByRole("button", { name: "Submit Dispute" }).click(); + + await expect.poll(() => getFiledRequest()).toMatchObject({ + payload: { + documentId: DOCUMENT_ID, + description: DESCRIPTION, + }, + authorization: `Bearer ${ACCESS_TOKEN}`, + }); + await expect(modalHeading).toBeHidden(); + await expect(page.getByText("Dispute Filed")).toBeVisible(); + await expect(page.getByText("Boundary mismatch")).toBeVisible(); + await expect(page.getByText(DESCRIPTION)).toBeVisible(); + const filedRow = page.getByRole("row").filter({ hasText: DESCRIPTION }); + await expect(filedRow.getByRole("cell").nth(2)).toHaveText("open"); + }); +}); diff --git a/frontend/jest.config.js b/frontend/jest.config.js index fb05fb1c..332f9cb7 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -15,20 +15,52 @@ const config = { }, setupFilesAfterEnv: ["@testing-library/jest-dom", "./test-utils/test-setup.ts"], testMatch: [ + "**/app/**/*.{test,spec}.{ts,tsx}", "**/test-utils/**/*.test.{ts,tsx}", "**/components/**/*.test.{ts,tsx}", "**/lib/**/*.test.{ts,tsx}", "**/i18n/**/*.test.{ts,tsx}", + "**/__tests__/**/*.{test,spec}.{ts,tsx}", ], moduleNameMapper: { "^@/(.*)$": "