From c5147921ed361178817d5d9fb7e4a472b7db3c3a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:00:22 +0000 Subject: [PATCH 1/2] Fix low-contrast dark-theme text and Users row overlap on 6 admin pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analytics, Press Releases, Live Streams, Audit Reports, Procurement, and Users were built with dark-theme classes (text-white, text-green- 200/*, bg-white/5, border-white/10) assuming a dark page background, but the admin content area is actually light (bg-slate-50) — headings, subtitles, stat labels, search placeholders, and pagination text were all rendering nearly invisible (white/pale-green on white). Converted all six to the light theme already used correctly elsewhere in the admin panel (LGA Approvals, Wards, Payments, Allocations). The Users list row had the same non-stacking layout bug fixed earlier on LGA Approvals — avatar, name, badges, and Suspend/Ban buttons were forced into one non-wrapping row, so on mobile names and emails truncated to a single character ("K…", "ke…") and buttons crowded the text. Now stacks properly on narrow screens. Also found the root cause of a stray corrupted byte discovered while editing press-releases: the previous session's arrow-mojibake fix (sed replacing "â†" with "←") left a dangling C1 control byte (U+0090) immediately after the arrow in 5 files, invisible in normal viewing but preventing exact-string edits on those lines. Fixed at the byte level and confirmed zero remaining control-character corruption across the whole app/ tree. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UPJE5cNDtQ4j7yeg9Xjtxw --- app/(admin)/admin/analytics/page.tsx | 82 +++++++++++------------ app/(admin)/admin/audit-reports/page.tsx | 16 ++--- app/(admin)/admin/lgas/page.tsx | 2 +- app/(admin)/admin/live-streams/page.tsx | 6 +- app/(admin)/admin/press-releases/page.tsx | 18 ++--- app/(admin)/admin/procurement/page.tsx | 18 ++--- app/(admin)/admin/revenue/page.tsx | 2 +- app/(admin)/admin/users/page.tsx | 68 ++++++++++--------- 8 files changed, 107 insertions(+), 105 deletions(-) diff --git a/app/(admin)/admin/analytics/page.tsx b/app/(admin)/admin/analytics/page.tsx index c5b44ad..5d6c40d 100644 --- a/app/(admin)/admin/analytics/page.tsx +++ b/app/(admin)/admin/analytics/page.tsx @@ -43,24 +43,24 @@ function StatCard({ icon: Icon, label, value, sub, color = "text-green-600" }: { sub?: string; color?: string; }) { return ( -
+
-
+
-

{label}

+

{label}

-

{value}

- {sub &&

{sub}

} +

{value}

+ {sub &&

{sub}

}
); } function nairaK(koboStr: string) { const naira = (koboStr && koboStr !== "0" ? Number(BigInt(koboStr)) : 0) / 100; - if (naira >= 1_000_000) return `₦${(naira / 1_000_000).toFixed(1)}M`; - if (naira >= 1_000) return `₦${(naira / 1_000).toFixed(1)}K`; - return `₦${naira.toFixed(2)}`; + if (naira >= 1_000_000) return `₦${(naira / 1_000_000).toFixed(1)}M`; + if (naira >= 1_000) return `₦${(naira / 1_000).toFixed(1)}K`; + return `₦${naira.toFixed(2)}`; } export default function AdminAnalyticsPage() { @@ -78,14 +78,14 @@ export default function AdminAnalyticsPage() { if (loading) { return (
- {[0,1,2].map((i) =>
)} + {[0,1,2].map((i) =>
)}
); } if (!data) { return ( -
+
Failed to load analytics.
); @@ -102,10 +102,10 @@ export default function AdminAnalyticsPage() {
- -

Platform Analytics

+ +

Platform Analytics

-

Real-time overview of platform activity

+

Real-time overview of platform activity

{/* Key metrics */} @@ -123,22 +123,22 @@ export default function AdminAnalyticsPage() { {/* Charts */}
{/* Monthly revenue bar chart */} -
-

Monthly Revenue (₦)

+
+

Monthly Revenue (₦)

{revenueChartData.length === 0 ? ( -
+
No revenue data yet
) : ( - - - `₦${(Number(v) / 1000).toFixed(0)}K`} /> + + + `₦${(Number(v) / 1000).toFixed(0)}K`} /> [`₦${Number(v).toLocaleString()}`, "Revenue"]} + contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 12, color: "#0f172a" }} + formatter={(v) => [`₦${Number(v).toLocaleString()}`, "Revenue"]} /> @@ -147,8 +147,8 @@ export default function AdminAnalyticsPage() {
{/* LGA status pie */} -
-

LGA Status Breakdown

+
+

LGA Status Breakdown

[v, name]} /> @@ -167,37 +167,37 @@ export default function AdminAnalyticsPage() {
{/* User role breakdown */} -
-

User Breakdown by Role

+
+

User Breakdown by Role

{userRoleData.map(({ name, value }) => ( -
-

{value.toLocaleString()}

-

{name.replace("_", " ")}

+
+

{value.toLocaleString()}

+

{name.replace("_", " ")}

))}
{/* Top LGAs by posts */} -
-
- -

Top LGAs by Content

+
+
+ +

Top LGAs by Content

{data.topLgasByPosts.length === 0 ? ( -
No data yet
+
No data yet
) : ( -
+
{data.topLgasByPosts.map((lga, idx) => (
- {idx + 1} + {idx + 1}
-

{lga.lgaName} LGA

-

{lga.state} · {lga.status}

+

{lga.lgaName} LGA

+

{lga.state} · {lga.status}

-
- +
+ {lga._count.posts}
diff --git a/app/(admin)/admin/audit-reports/page.tsx b/app/(admin)/admin/audit-reports/page.tsx index 6da7a43..93d53d4 100644 --- a/app/(admin)/admin/audit-reports/page.tsx +++ b/app/(admin)/admin/audit-reports/page.tsx @@ -119,10 +119,10 @@ export default function AdminAuditReportsPage() {
- -

Audit Reports

+ +

Audit Reports

-

{total.toLocaleString()} audit report records

+

{total.toLocaleString()} audit report records

@@ -192,13 +192,13 @@ export default function AdminAuditReportsPage() { {/* Pagination */} {Math.ceil(total / PAGE) > 1 && ( -
-

Showing {page * PAGE + 1}–{Math.min((page + 1) * PAGE, total)} of {total}

+
+

Showing {page * PAGE + 1}–{Math.min((page + 1) * PAGE, total)} of {total}

+ className="px-4 py-2 rounded-xl border border-slate-200 text-slate-600 text-sm disabled:opacity-40 hover:bg-slate-50">← Prev + className="px-4 py-2 rounded-xl border border-slate-200 text-slate-600 text-sm disabled:opacity-40 hover:bg-slate-50">Next →
)} diff --git a/app/(admin)/admin/lgas/page.tsx b/app/(admin)/admin/lgas/page.tsx index f47ac93..2932408 100644 --- a/app/(admin)/admin/lgas/page.tsx +++ b/app/(admin)/admin/lgas/page.tsx @@ -637,7 +637,7 @@ export default function AdminLGAsPage() {
))}
@@ -265,13 +265,13 @@ export default function AdminPressReleasesPage() { {/* Pagination */} {Math.ceil(total / PAGE) > 1 && ( -
-

Showing {page * PAGE + 1}–{Math.min((page + 1) * PAGE, total)} of {total}

+
+

Showing {page * PAGE + 1}–{Math.min((page + 1) * PAGE, total)} of {total}

+ className="px-4 py-2 rounded-xl border border-slate-200 text-slate-600 text-sm disabled:opacity-40 hover:bg-slate-50">← Prev + className="px-4 py-2 rounded-xl border border-slate-200 text-slate-600 text-sm disabled:opacity-40 hover:bg-slate-50">Next →
)} diff --git a/app/(admin)/admin/procurement/page.tsx b/app/(admin)/admin/procurement/page.tsx index 7f7da0e..115cf25 100644 --- a/app/(admin)/admin/procurement/page.tsx +++ b/app/(admin)/admin/procurement/page.tsx @@ -124,10 +124,10 @@ export default function AdminProcurementPage() {
- -

Procurement Contracts

+ +

Procurement Contracts

-

{total.toLocaleString()} contract records

+

{total.toLocaleString()} contract records

@@ -192,13 +192,13 @@ export default function AdminProcurementPage() { {/* Pagination */} {Math.ceil(total / PAGE) > 1 && ( -
-

Showing {page * PAGE + 1}–{Math.min((page + 1) * PAGE, total)} of {total}

+
+

Showing {page * PAGE + 1}–{Math.min((page + 1) * PAGE, total)} of {total}

+ className="px-4 py-2 rounded-xl border border-slate-200 text-slate-600 text-sm disabled:opacity-40 hover:bg-slate-50">← Prev + className="px-4 py-2 rounded-xl border border-slate-200 text-slate-600 text-sm disabled:opacity-40 hover:bg-slate-50">Next →
)} @@ -254,7 +254,7 @@ export default function AdminProcurementPage() { {[ { key: "title", label: "Contract Title *", placeholder: "e.g. Construction of Ward 3 Road" }, { key: "contractor", label: "Contractor Name *", placeholder: "e.g. ABC Construction Ltd" }, - { key: "value", label: "Contract Value (₦) *", placeholder: "e.g. 15000000" }, + { key: "value", label: "Contract Value (₦) *", placeholder: "e.g. 15000000" }, ].map(({ key, label, placeholder }) => (
diff --git a/app/(admin)/admin/revenue/page.tsx b/app/(admin)/admin/revenue/page.tsx index 3337047..07455b3 100644 --- a/app/(admin)/admin/revenue/page.tsx +++ b/app/(admin)/admin/revenue/page.tsx @@ -166,7 +166,7 @@ export default function AdminRevenuePage() { - `₦${(v / 1000).toFixed(0)}k`} /> + `₦${(v / 1000).toFixed(0)}k`} /> new Intl.NumberFormat("en-NG", { diff --git a/app/(admin)/admin/users/page.tsx b/app/(admin)/admin/users/page.tsx index 4cce163..4bc0578 100644 --- a/app/(admin)/admin/users/page.tsx +++ b/app/(admin)/admin/users/page.tsx @@ -132,18 +132,18 @@ export default function AdminUsersPage() {
- -

User Management

+ +

User Management

-

{total.toLocaleString()} total accounts

+

{total.toLocaleString()} total accounts

{/* Role tabs */} -
+
{ROLE_TABS.map(({ label, value }) => ( @@ -157,15 +157,15 @@ export default function AdminUsersPage() { setSearch(e.target.value)} - className="w-full pl-9 pr-4 py-2.5 rounded-xl border border-white/20 bg-white/10 text-white placeholder:text-green-200/40 text-sm focus:outline-none focus:border-green-400" + className="w-full pl-9 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white text-slate-800 placeholder:text-slate-400 text-sm focus:outline-none focus:border-green-400" />
@@ -192,27 +192,29 @@ export default function AdminUsersPage() {
{users.map((user) => (
-
- {/* Avatar */} -
- {user.name.charAt(0).toUpperCase()} -
+
+
+ {/* Avatar */} +
+ {user.name.charAt(0).toUpperCase()} +
-
-
- {user.name} - - {user.isBanned && ( - BANNED - )} - {!user.isBanned && user.suspendedUntil && new Date(user.suspendedUntil) > new Date() && ( - SUSPENDED - )} +
+
+ {user.name} + + {user.isBanned && ( + BANNED + )} + {!user.isBanned && user.suspendedUntil && new Date(user.suspendedUntil) > new Date() && ( + SUSPENDED + )} +
+

{user.email}

-

{user.email}

-
+
{user.isBanned ? (
From 0ffc87c6555150281799a5b24d5fb66b6be0493d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:50:12 +0000 Subject: [PATCH 2/2] Cap total LGA count at 774 on registration, with a seed-secret test bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nigeria has exactly 774 LGAs, but POST /api/lga/register had no limit on how many rows could be created — the admin dashboard could accumulate unlimited fake LGAs. Blocks the "fresh create" branch (no matching seeded row) once the total hits 774; the "claim a seeded LGA" branch is untouched so real chairmen can always register against the official 774 seeded via /api/admin/seed. Test/seed scripts bypass the cap with the existing SEED_SECRET via an x-seed-secret header. Updates the Playwright suite's registration helpers to send the bypass header (the local test DB already exceeds 774 from prior test runs), and adds regression coverage for both the cap and the seeded-claim exemption. --- .env.example | 8 ++- app/api/lga/register/route.ts | 24 ++++++++ playwright.config.ts | 5 ++ tests/e2e/admin-browser.spec.ts | 2 +- tests/e2e/admin-lga-ward-e2e.spec.ts | 1 + tests/e2e/auth-e2e.spec.ts | 6 +- tests/e2e/chairman-crud-e2e.spec.ts | 1 + tests/e2e/citizen-lga-e2e.spec.ts | 1 + tests/e2e/fr01-fr03.spec.ts | 82 +++++++++++++++++++++++-- tests/e2e/investor-browser.spec.ts | 2 +- tests/e2e/investor-lga-e2e.spec.ts | 1 + tests/e2e/lga-dashboard-browser.spec.ts | 2 +- tests/e2e/lga-portal-auth-e2e.spec.ts | 1 + tests/e2e/payments-e2e.spec.ts | 1 + 14 files changed, 128 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index f4c78b1..1d17208 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,13 @@ ADMIN_SECRET="" ADMIN_PASSWORD="" ADMIN_EMAIL="" +# ── Seed / test bypass ────────────────────────────────────────────────────── +# Guards POST /api/admin/seed (loads the official 774 LGAs) and lets test +# scripts create LGAs past the 774-total cap on POST /api/lga/register by +# sending it as an `x-seed-secret` header. Leave unset in production once +# real chairmen are registering — there's no legitimate reason to exceed 774. +SEED_SECRET="" + # ── File uploads (Cloudinary) ─────────────────────────────────────────────── # Create a free account at https://cloudinary.com and copy these from your # dashboard. Used for project images/video and press release attachments — @@ -54,4 +61,3 @@ CONTACT_EMAIL="" # ── Misc ───────────────────────────────────────────────────────────────────── NEXT_PUBLIC_APP_URL="http://localhost:3000" OPENAI_API_KEY="" -SEED_SECRET="" diff --git a/app/api/lga/register/route.ts b/app/api/lga/register/route.ts index c770a98..e671306 100644 --- a/app/api/lga/register/route.ts +++ b/app/api/lga/register/route.ts @@ -6,6 +6,16 @@ import { lgaSignUpSchema } from "@/lib/validations"; import { generateToken, sanitizeInput } from "@/lib/utils"; import { sendLGAVerificationEmail } from "@/lib/email"; +// Nigeria has exactly 774 Local Government Areas. Once /api/admin/seed has +// loaded the official 774 (see prisma/seeds/nigeria-lgas.ts), every real +// registration matches a seeded row by name+state and goes through the +// "claim" path below (an update, not a create) — so this only ever blocks +// the "fresh create" branch, which is where anything that isn't one of the +// 774 official LGAs would otherwise slip in. Test/seed scripts can bypass +// this with the same secret that guards /api/admin/seed. +const TOTAL_LGA_CAP = 774; +const SEED_SECRET = process.env.SEED_SECRET ?? ""; + export async function POST(request: Request) { // Rate limit: 3 per hour per IP const ip = getClientIP(request); @@ -108,6 +118,20 @@ export async function POST(request: Request) { include: { chairman: { select: { id: true } } }, }); } else { + // No seed record — this would create an LGA that isn't one of + // Nigeria's official 774. Blocked unless the request carries the + // seed-secret test bypass. + const isTestBypass = Boolean(SEED_SECRET) && request.headers.get("x-seed-secret") === SEED_SECRET; + if (!isTestBypass) { + const totalLgas = await db.lGA.count(); + if (totalLgas >= TOTAL_LGA_CAP) { + return NextResponse.json( + { error: "Nigeria has 774 Local Government Areas and that limit has been reached. If your LGA isn't listed, contact support." }, + { status: 409 } + ); + } + } + // No seed record — create fresh lga = await db.lGA.create({ data: { diff --git a/playwright.config.ts b/playwright.config.ts index 35c0ae6..101a65b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,9 @@ import { defineConfig, devices } from "@playwright/test"; +import { loadEnvConfig } from "@next/env"; + +// Load .env.local the same way `next dev` does, so tests can read +// SEED_SECRET (and anything else app code reads from process.env). +loadEnvConfig(process.cwd()); export default defineConfig({ testDir: "./tests/e2e", diff --git a/tests/e2e/admin-browser.spec.ts b/tests/e2e/admin-browser.spec.ts index c041571..35bc8ac 100644 --- a/tests/e2e/admin-browser.spec.ts +++ b/tests/e2e/admin-browser.spec.ts @@ -65,7 +65,7 @@ async function seedPendingLGA(request: APIRequestContext, ip: string): Promise<{ const email = `chairman_adm_${suffix}@example.com`; const reg = await request.post("/api/lga/register", { - headers: { "x-forwarded-for": ip }, + headers: { "x-forwarded-for": ip, "x-seed-secret": process.env.SEED_SECRET ?? "" }, data: { lgaName, state: "Kano", chairmanName: "Alhaji Admin", email, phone: "08011223344", officeAddress: "3 Council Close, Kano", diff --git a/tests/e2e/admin-lga-ward-e2e.spec.ts b/tests/e2e/admin-lga-ward-e2e.spec.ts index d4488d0..e61b210 100644 --- a/tests/e2e/admin-lga-ward-e2e.spec.ts +++ b/tests/e2e/admin-lga-ward-e2e.spec.ts @@ -53,6 +53,7 @@ async function seedApprovedLGA(ip: string): Promise<{ id: string; email: string; const ctx = await apiRequest.newContext({ baseURL: BASE, extraHTTPHeaders: { "x-forwarded-for": ip } }); const reg = await ctx.post("/api/lga/register", { + headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" }, data: { lgaName, state, chairmanName: "Chief Record", email, phone: "08012345678", officeAddress: "1 Record Road, Ikeja", sectors: ["Health"], diff --git a/tests/e2e/auth-e2e.spec.ts b/tests/e2e/auth-e2e.spec.ts index 08c487a..4df06c7 100644 --- a/tests/e2e/auth-e2e.spec.ts +++ b/tests/e2e/auth-e2e.spec.ts @@ -263,7 +263,10 @@ test.describe("Auth E2E — LGA Chairman", () => { test("registration → 201 with lgaId", async () => { const ctx = await ctxForIp(IP); - const res = await ctx.post("/api/lga/register", { data: registrationPayload }); + const res = await ctx.post("/api/lga/register", { + data: registrationPayload, + headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" }, + }); expect(res.status()).toBe(201); const body = await res.json(); expect(body.success).toBe(true); @@ -309,6 +312,7 @@ test.describe("Auth E2E — LGA Chairman", () => { const ctx = await ctxForIp(IP); const res = await ctx.post("/api/lga/register", { data: { ...registrationPayload, lgaName: `${lgaName} Two` }, + headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" }, }); expect(res.status()).toBe(409); }); diff --git a/tests/e2e/chairman-crud-e2e.spec.ts b/tests/e2e/chairman-crud-e2e.spec.ts index 438f92e..56015b6 100644 --- a/tests/e2e/chairman-crud-e2e.spec.ts +++ b/tests/e2e/chairman-crud-e2e.spec.ts @@ -64,6 +64,7 @@ async function authedLGA(ip: string): Promise<{ ctx: APIRequestContext; lgaId: s const ctx = await ctxForIp(ip); const reg = await ctx.post("/api/lga/register", { + headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" }, data: { lgaName: `Governville ${suffix}`, state: "Lagos", chairmanName: "Chief Govern", email, phone: "08012345678", officeAddress: "1 Council Road, Ikeja", diff --git a/tests/e2e/citizen-lga-e2e.spec.ts b/tests/e2e/citizen-lga-e2e.spec.ts index af256cb..3836fc9 100644 --- a/tests/e2e/citizen-lga-e2e.spec.ts +++ b/tests/e2e/citizen-lga-e2e.spec.ts @@ -120,6 +120,7 @@ async function authedLGA(ip: string): Promise<{ ctx: APIRequestContext; lgaId: s const ctx = await ctxForIp(ip); const reg = await ctx.post("/api/lga/register", { + headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" }, data: { lgaName, state: "Lagos", chairmanName: "Chief Portal", email, phone: "08012345678", officeAddress: "1 Council Road, Ikeja", sectors: ["Health", "Education"], diff --git a/tests/e2e/fr01-fr03.spec.ts b/tests/e2e/fr01-fr03.spec.ts index 4a04b9c..8f5d81e 100644 --- a/tests/e2e/fr01-fr03.spec.ts +++ b/tests/e2e/fr01-fr03.spec.ts @@ -10,8 +10,11 @@ */ import { test, expect, request as apiRequest } from "@playwright/test"; +import { Pool } from "pg"; const BASE = "http://localhost:3000"; +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +test.afterAll(async () => { await pool.end(); }); async function apiPost( url: string, @@ -37,6 +40,10 @@ async function apiGet(url: string, headers: Record = {}) { const ADMIN = { "x-admin-secret": process.env.NEXT_PUBLIC_ADMIN_SECRET ?? "4a0423d4888f73e76fbbb5655ac5458c09be34d5d4eaa9522f943b9cc3d80666" }; +// Bypasses the 774-LGA total cap on POST /api/lga/register (see route.ts) — +// the local test DB accumulates far more than 774 rows across test runs. +const SEED_HEADER = { "x-seed-secret": process.env.SEED_SECRET ?? "" }; + // ─── FR-01-01: Citizen Registration ───────────────────────────────────────── test.describe("FR-01-01: Citizen Registration — API", () => { @@ -272,7 +279,7 @@ test.describe("FR-02-01: LGA Registration — API contracts", () => { password: "Secure@123", confirmPassword: "Secure@123", terms: true, - }); + }, SEED_HEADER); expect([201, 429]).toContain(status); if (status === 201) { expect(body.success).toBe(true); @@ -294,11 +301,11 @@ test.describe("FR-02-01: LGA Registration — API contracts", () => { confirmPassword: "Secure@123", terms: true, }; - await apiPost("/api/lga/register", { ...base, email: `lga1+${ts}@mailinator.com` }); + await apiPost("/api/lga/register", { ...base, email: `lga1+${ts}@mailinator.com` }, SEED_HEADER); const { status } = await apiPost("/api/lga/register", { ...base, email: `lga2+${ts}@mailinator.com`, - }); + }, SEED_HEADER); // 409 on dup, or 429 if rate-limited (3 per hour) expect([409, 429]).toContain(status); }); @@ -314,12 +321,79 @@ test.describe("FR-02-01: LGA Registration — API contracts", () => { password: "Secure@123", confirmPassword: "Secure@123", terms: true, - }); + }, SEED_HEADER); expect([400, 429]).toContain(status); if (status === 400) expect(typeof body.error).toBe("string"); }); }); +// ─── 774-LGA total cap guard ──────────────────────────────────────────────── + +test.describe("774-LGA cap guard on POST /api/lga/register", () => { + test("a fresh LGA create is blocked once the total reaches 774, but the seed-secret bypass still works", async () => { + const { rows } = await pool.query("SELECT count(*)::int AS count FROM lgas"); + test.skip(rows[0].count < 774, "local DB has fewer than 774 LGAs; the cap hasn't kicked in yet"); + + const ctx = await apiRequest.newContext({ + baseURL: BASE, + extraHTTPHeaders: { "x-forwarded-for": `198.30.${Math.floor(Math.random() * 254) + 1}.1` }, + }); + const ts = Date.now(); + const payload = (suffix: string) => ({ + lgaName: `Cap Guard Test ${ts}${suffix}`, + state: "Lagos", + chairmanName: "Cap Guard Tester", + email: `cap-guard-${ts}${suffix}@mailinator.com`, + phone: "08012345678", + officeAddress: "1 Cap Guard Road", + sectors: ["Health"], + password: "Secure@123", + confirmPassword: "Secure@123", + terms: true, + }); + + const blocked = await ctx.post("/api/lga/register", { data: payload("a") }); + expect(blocked.status()).toBe(409); + expect((await blocked.json()).error).toMatch(/774/); + + const bypassed = await ctx.post("/api/lga/register", { + data: payload("b"), + headers: SEED_HEADER, + }); + expect(bypassed.status()).toBe(201); + }); + + test("claiming an already-seeded LGA (no chairman yet) is never blocked by the cap", async () => { + const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`; + const lgaName = `Seed Claim Test ${suffix}`; + await pool.query( + `INSERT INTO lgas (id, "lgaName", state, "chairmanName", email, phone, "officeAddress", status, "isVerified", sectors, "createdAt", "updatedAt") + VALUES ($1, $2, 'Ogun', 'Vacant', $3, '08000000000', 'N/A', 'APPROVED', true, ARRAY[]::text[], now(), now())`, + [`captest${suffix}`.slice(0, 25), lgaName, `vacant-${suffix}@lga.gov.ng`] + ); + + const ctx = await apiRequest.newContext({ + baseURL: BASE, + extraHTTPHeaders: { "x-forwarded-for": `198.31.${Math.floor(Math.random() * 254) + 1}.1` }, + }); + const claim = await ctx.post("/api/lga/register", { + data: { + lgaName, + state: "Ogun", + chairmanName: "Real Chairman", + email: `real-chairman-${suffix}@mailinator.com`, + phone: "08012345678", + officeAddress: "1 Real Street", + sectors: ["Health"], + password: "Secure@123", + confirmPassword: "Secure@123", + terms: true, + }, + }); + expect(claim.status()).toBe(201); + }); +}); + // ─── FR-02-03: Admin LGA Approval ─────────────────────────────────────────── test.describe("FR-02-03: Admin Approval — API contracts", () => { diff --git a/tests/e2e/investor-browser.spec.ts b/tests/e2e/investor-browser.spec.ts index 6d5c3f7..2831185 100644 --- a/tests/e2e/investor-browser.spec.ts +++ b/tests/e2e/investor-browser.spec.ts @@ -68,7 +68,7 @@ async function seedApprovedLGAWithEndowment( // 1. Register const reg = await request.post("/api/lga/register", { - headers: { "x-forwarded-for": ip }, + headers: { "x-forwarded-for": ip, "x-seed-secret": process.env.SEED_SECRET ?? "" }, data: { lgaName, state: "Lagos", chairmanName: "Chief Investor", email, phone: "08012345678", officeAddress: "2 Investment Road, Ikeja", diff --git a/tests/e2e/investor-lga-e2e.spec.ts b/tests/e2e/investor-lga-e2e.spec.ts index b5c71dd..72ec814 100644 --- a/tests/e2e/investor-lga-e2e.spec.ts +++ b/tests/e2e/investor-lga-e2e.spec.ts @@ -69,6 +69,7 @@ async function authedLGA(ip: string): Promise<{ ctx: APIRequestContext; lgaId: s const ctx = await ctxForIp(ip); const reg = await ctx.post("/api/lga/register", { + headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" }, data: { lgaName: `Endowville ${suffix}`, state: "Lagos", chairmanName: "Chief Invest", email, phone: "08012345678", officeAddress: "1 Council Road, Ikeja", diff --git a/tests/e2e/lga-dashboard-browser.spec.ts b/tests/e2e/lga-dashboard-browser.spec.ts index 92343db..e97666c 100644 --- a/tests/e2e/lga-dashboard-browser.spec.ts +++ b/tests/e2e/lga-dashboard-browser.spec.ts @@ -147,7 +147,7 @@ async function seedVerifiedLGA( const suffix = uniq(); const email = `chairman_${suffix}@example.com`; const reg = await request.post("/api/lga/register", { - headers: { "x-forwarded-for": ip }, + headers: { "x-forwarded-for": ip, "x-seed-secret": process.env.SEED_SECRET ?? "" }, data: { lgaName: `Browserville ${suffix}`, state: "Lagos", chairmanName: "Chief Browser", email, phone: "08012345678", officeAddress: "1 Council Road, Ikeja", diff --git a/tests/e2e/lga-portal-auth-e2e.spec.ts b/tests/e2e/lga-portal-auth-e2e.spec.ts index dd0c82e..b2f840d 100644 --- a/tests/e2e/lga-portal-auth-e2e.spec.ts +++ b/tests/e2e/lga-portal-auth-e2e.spec.ts @@ -64,6 +64,7 @@ async function authedLGA(ip: string): Promise<{ ctx: APIRequestContext; lgaId: s const ctx = await ctxForIp(ip); const reg = await ctx.post("/api/lga/register", { + headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" }, data: { lgaName: `Portalville ${suffix}`, state: "Lagos", chairmanName: "Chief Portal", email, phone: "08012345678", officeAddress: "1 Council Road, Ikeja", diff --git a/tests/e2e/payments-e2e.spec.ts b/tests/e2e/payments-e2e.spec.ts index 90a8aec..bc41e43 100644 --- a/tests/e2e/payments-e2e.spec.ts +++ b/tests/e2e/payments-e2e.spec.ts @@ -64,6 +64,7 @@ async function authedLGA(ip: string): Promise<{ ctx: APIRequestContext; lgaId: s const c = await apiRequest.newContext({ baseURL: BASE, extraHTTPHeaders: { "x-forwarded-for": ip } }); const reg = await c.post("/api/lga/register", { + headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" }, data: { lgaName: `Payville ${suffix}`, state: "Lagos", chairmanName: "Chief Pay", email, phone: "08012345678", officeAddress: "1 Pay Road, Ikeja",