Skip to content
Open
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
570 changes: 570 additions & 0 deletions messages/fr.json

Large diffs are not rendered by default.

570 changes: 570 additions & 0 deletions messages/pt.json

Large diffs are not rendered by default.

132 changes: 132 additions & 0 deletions src/__tests__/app/api/admin-audit-log/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { NextResponse } from "next/server";

Check failure on line 1 in src/__tests__/app/api/admin-audit-log/route.test.ts

View workflow job for this annotation

GitHub Actions / Lint (ESLint + Prettier)

'NextResponse' is defined but never used

jest.mock("next/server", () => ({
NextResponse: {
json: (data: unknown) => ({ status: 200, json: async () => data }),
},
}));

const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100;

const parsePage = (raw: string | null): number => {
const parsed = parseInt(raw ?? "1", 10);
return Math.max(1, Number.isFinite(parsed) ? parsed : 1);
};

const parsePageSize = (raw: string | null): number => {
const parsed = parseInt(raw ?? String(DEFAULT_PAGE_SIZE), 10);
return Math.min(MAX_PAGE_SIZE, Math.max(1, Number.isFinite(parsed) ? parsed : DEFAULT_PAGE_SIZE));
};

const pickFields = <T>(item: T, fields: string[]): Partial<T> => {
const picked: Record<string, unknown> = {};
for (const field of fields) {
if (field in item) {
picked[field] = (item as Record<string, unknown>)[field];
}
}
return picked as Partial<T>;
};

describe("admin-audit-log route pagination helpers", () => {
it("parses page correctly", () => {
expect(parsePage(null)).toBe(1);
expect(parsePage("1")).toBe(1);
expect(parsePage("5")).toBe(5);
expect(parsePage("0")).toBe(1);
expect(parsePage("-1")).toBe(1);
expect(parsePage("abc")).toBe(1);
});

it("parses pageSize correctly", () => {
expect(parsePageSize(null)).toBe(20);
expect(parsePageSize("10")).toBe(10);
expect(parsePageSize("100")).toBe(100);
expect(parsePageSize("0")).toBe(1);
expect(parsePageSize("999")).toBe(100);
expect(parsePageSize("abc")).toBe(20);
});

it("picks only requested fields", () => {
const item = { adminAddress: "GAAAA", action: "verify", txHash: "0x123", timestamp: 1000 };
expect(pickFields(item, ["adminAddress", "action"])).toEqual({
adminAddress: "GAAAA",
action: "verify",
});
expect(pickFields(item, ["txHash"])).toEqual({ txHash: "0x123" });
expect(pickFields(item, ["missing"])).toEqual({});
expect(pickFields(item, [])).toEqual({});
});
});

describe("admin-audit-log route pagination logic", () => {
const buildEntries = (count: number) =>
Array.from({ length: count }, (_, i) => ({
adminAddress: "GAAAAAAA",
action: "verify_campaign" as const,
txHash: `tx-${i}`,
timestamp: 1000 + i,
campaignId: i,
details: `details-${i}`,
}));

it("paginates a list of entries", () => {
const entries = buildEntries(45);
const page = 1;
const pageSize = 20;
const start = (page - 1) * pageSize;
const pageEntries = entries.slice(start, start + pageSize);

expect(pageEntries).toHaveLength(20);
expect(pageEntries[0]).toEqual(entries[0]);
expect(pageEntries[19]).toEqual(entries[19]);
});

it("returns the last page with fewer items", () => {
const entries = buildEntries(45);
const page = 3;
const pageSize = 20;
const start = (page - 1) * pageSize;
const pageEntries = entries.slice(start, start + pageSize);

expect(pageEntries).toHaveLength(5);
expect(pageEntries[0]).toEqual(entries[40]);
});

it("filters entries by adminAddress", () => {
const entries = [
{ adminAddress: "GAAAAAAA", action: "verify_campaign" as const, txHash: "tx-1", timestamp: 1000 },
{ adminAddress: "GBBBBBBB", action: "reject_campaign" as const, txHash: "tx-2", timestamp: 1001 },
{ adminAddress: "GAAAAAAA", action: "update_platform_fee" as const, txHash: "tx-3", timestamp: 1002 },
];

const normalized = "GAAAAAAA";
const filtered = entries.filter((e) => e.adminAddress.toUpperCase() === normalized);
expect(filtered).toHaveLength(2);
expect(filtered.every((e) => e.adminAddress === "GAAAAAAA")).toBe(true);
});

it("filters entries by action", () => {
const entries = [
{ adminAddress: "GAAAAAAA", action: "verify_campaign" as const, txHash: "tx-1", timestamp: 1000 },
{ adminAddress: "GAAAAAAA", action: "reject_campaign" as const, txHash: "tx-2", timestamp: 1001 },
{ adminAddress: "GAAAAAAA", action: "verify_campaign" as const, txHash: "tx-3", timestamp: 1002 },
];

const action = "verify_campaign";
const filtered = entries.filter((e) => e.action === action);
expect(filtered).toHaveLength(2);
expect(filtered.every((e) => e.action === "verify_campaign")).toBe(true);
});

it("applies sparse fieldsets correctly", () => {
const entries = buildEntries(3);
const fields = ["adminAddress", "action"];
const picked = entries.map((entry) => pickFields(entry, fields));

expect(picked[0]).toEqual({ adminAddress: "GAAAAAAA", action: "verify_campaign" });
expect(picked[0]).not.toHaveProperty("txHash");
expect(picked[0]).not.toHaveProperty("timestamp");
});
});
112 changes: 112 additions & 0 deletions src/__tests__/app/api/reports/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { NextResponse } from "next/server";

Check failure on line 1 in src/__tests__/app/api/reports/route.test.ts

View workflow job for this annotation

GitHub Actions / Lint (ESLint + Prettier)

'NextResponse' is defined but never used

jest.mock("next/server", () => ({
NextResponse: {
json: (data: unknown) => ({ status: 200, json: async () => data }),
},
}));

const mockRequest = (url: string, searchParams: Record<string, string> = {}) => ({
url: searchParams.size
? `${url}?${new URLSearchParams(searchParams).toString()}`
: url,
});

describe("reports route pagination helpers", () => {
const parsePage = (raw: string | null): number => {
const parsed = parseInt(raw ?? "1", 10);
return Math.max(1, Number.isFinite(parsed) ? parsed : 1);
};

const parsePageSize = (raw: string | null): number => {
const parsed = parseInt(raw ?? "20", 10);
return Math.min(100, Math.max(1, Number.isFinite(parsed) ? parsed : 20));
};

const pickFields = <T>(item: T, fields: string[]): Partial<T> => {
const picked: Record<string, unknown> = {};
for (const field of fields) {
if (field in item) {
picked[field] = (item as Record<string, unknown>)[field];
}
}
return picked as Partial<T>;
};

it("parses page correctly", () => {
expect(parsePage(null)).toBe(1);
expect(parsePage("1")).toBe(1);
expect(parsePage("5")).toBe(5);
expect(parsePage("0")).toBe(1);
expect(parsePage("-1")).toBe(1);
expect(parsePage("abc")).toBe(1);
});

it("parses pageSize correctly", () => {
expect(parsePageSize(null)).toBe(20);
expect(parsePageSize("10")).toBe(10);
expect(parsePageSize("100")).toBe(100);
expect(parsePageSize("0")).toBe(1);
expect(parsePageSize("999")).toBe(100);
expect(parsePageSize("abc")).toBe(20);
});

it("picks only requested fields", () => {
const item = { id: "1", name: "test", extra: "data" };
expect(pickFields(item, ["id", "name"])).toEqual({ id: "1", name: "test" });
expect(pickFields(item, ["id"])).toEqual({ id: "1" });
expect(pickFields(item, ["missing"])).toEqual({});
expect(pickFields(item, [])).toEqual({});
});
});

describe("reports route integration", () => {
let reportStore: Array<{
id: string;
campaignId: number;
campaignTitle: string;
reason: string;
notes: string;
reporterAddress: string | null;
timestamp: number;
status: "pending" | "reviewed";
}>;

beforeEach(() => {
jest.resetModules();
reportStore = [];
jest.doMock("@/lib/reportStore", () => ({
reportStore,
}));
});

it("paginates and filters reports via mocked route", async () => {
for (let i = 0; i < 45; i++) {
reportStore.push({
id: `report-${i}`,
campaignId: i,
campaignTitle: `Campaign ${i}`,
reason: "scam",
notes: "notes",
reporterAddress: "GABC",
timestamp: 1000 + i,
status: i % 2 === 0 ? "pending" : "reviewed",
});
}

const { GET } = await import("@/app/api/reports/route");

const req1 = mockRequest("http://localhost/api/reports", {
page: "1",
pageSize: "20",
}) as any;
const res1 = await GET(req1);
const data1 = await (res1 as any).json();

expect(data1.items).toHaveLength(20);
expect(data1.total).toBe(45);
expect(data1.page).toBe(1);
expect(data1.pageSize).toBe(20);
expect(data1.hasMore).toBe(true);
});
});
55 changes: 46 additions & 9 deletions src/app/api/admin-audit-log/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,58 @@ function normalizeAddress(address: string): string {
return address.trim().toUpperCase();
}

const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100;

function parsePageSize(raw: string | null): number {
const parsed = parseInt(raw ?? String(DEFAULT_PAGE_SIZE), 10);
return Math.min(MAX_PAGE_SIZE, Math.max(1, Number.isFinite(parsed) ? parsed : DEFAULT_PAGE_SIZE));
}

function parsePage(raw: string | null): number {
const parsed = parseInt(raw ?? "1", 10);
return Math.max(1, Number.isFinite(parsed) ? parsed : 1);
}

function pickFields<T>(item: T, fields: string[]): Partial<T> {
const picked: Record<string, unknown> = {};
for (const field of fields) {
if (field in item) {
picked[field] = (item as Record<string, unknown>)[field];
}
}
return picked as Partial<T>;
}

// GET /api/admin-audit-log?page=1&pageSize=20&adminAddress=...&action=...&fields=...
export async function GET(request: Request) {
const url = new URL(request.url);
const adminAddress = url.searchParams.get("adminAddress");
const entries = await readEntries();
const action = url.searchParams.get("action");
const page = parsePage(url.searchParams.get("page"));
const pageSize = parsePageSize(url.searchParams.get("pageSize"));
const fields = url.searchParams.get("fields");
const requestedFields = fields ? fields.split(",").map((f) => f.trim()).filter(Boolean) : [];

let entries = await readEntries();

if (!adminAddress) {
return NextResponse.json({ entries });
if (adminAddress) {
const normalized = normalizeAddress(adminAddress);
entries = entries.filter((entry) => normalizeAddress(entry.adminAddress) === normalized);
}

const normalized = normalizeAddress(adminAddress);
return NextResponse.json({
entries: entries
.filter((entry) => normalizeAddress(entry.adminAddress) === normalized)
.sort((a, b) => b.timestamp - a.timestamp),
});
if (action) {
entries = entries.filter((entry) => entry.action === action);
}

entries.sort((a, b) => b.timestamp - a.timestamp);
const total = entries.length;
const start = (page - 1) * pageSize;
const pageEntries = entries.slice(start, start + pageSize).map((entry) =>
requestedFields.length > 0 ? pickFields(entry, requestedFields) : entry,
);

return NextResponse.json({ entries: pageEntries, total, page, pageSize, hasMore: page * pageSize < total });
}

export async function POST(request: Request) {
Expand Down
41 changes: 37 additions & 4 deletions src/app/api/reports/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,53 @@ function checkRateLimit(key: string): boolean {
return true;
}

// GET /api/reports — admin moderation queue
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100;

function parsePageSize(raw: string | null): number {
const parsed = parseInt(raw ?? String(DEFAULT_PAGE_SIZE), 10);
return Math.min(MAX_PAGE_SIZE, Math.max(1, Number.isFinite(parsed) ? parsed : DEFAULT_PAGE_SIZE));
}

function parsePage(raw: string | null): number {
const parsed = parseInt(raw ?? "1", 10);
return Math.max(1, Number.isFinite(parsed) ? parsed : 1);
}

function pickFields<T>(item: T, fields: string[]): Partial<T> {
const picked: Record<string, unknown> = {};
for (const field of fields) {
if (field in item) {
picked[field] = (item as Record<string, unknown>)[field];
}
}
return picked as Partial<T>;
}

// GET /api/reports?page=1&pageSize=20&status=all&fields=id,campaignId,status
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const status = url.searchParams.get("status") ?? "all";
const page = parsePage(url.searchParams.get("page"));
const pageSize = parsePageSize(url.searchParams.get("pageSize"));
const fields = url.searchParams.get("fields");
const requestedFields = fields ? fields.split(",").map((f) => f.trim()).filter(Boolean) : [];

const results =
const filtered =
status === "pending"
? reportStore.filter((r) => r.status === "pending")
: status === "reviewed"
? reportStore.filter((r) => r.status === "reviewed")
: [...reportStore];

results.sort((a, b) => b.timestamp - a.timestamp);
return NextResponse.json(results);
filtered.sort((a, b) => b.timestamp - a.timestamp);
const total = filtered.length;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize).map((item) =>
requestedFields.length > 0 ? pickFields(item, requestedFields) : item,
);

return NextResponse.json({ items, total, page, pageSize, hasMore: page * pageSize < total });
}

// POST /api/reports — submit a new abuse report
Expand Down
Loading
Loading