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
35 changes: 35 additions & 0 deletions src/__tests__/AuditLogViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import AuditLogViewer from "@/components/audit/AuditLogViewer";
import { auditEntriesToCsv, filterAuditEntries, type AuditEntry } from "@/lib/auditLog";

const entries: AuditEntry[] = [
{ id: "1", timestamp: 1000, actor: "GALICE", action: "Invoice created", category: "invoice", target: "INV-1" },
{ id: "2", timestamp: 3000, actor: "GBOB", action: "Signed in", category: "auth" },
{ id: "3", timestamp: 2000, actor: "GALICE", action: "Payment sent", category: "payment", details: 'note, "quoted"' },
];

describe("auditLog", () => {
it("sorts newest first and filters by category, query and range", () => {
expect(filterAuditEntries(entries).map((e) => e.id)).toEqual(["2", "3", "1"]);
expect(filterAuditEntries(entries, { category: "auth" }).map((e) => e.id)).toEqual(["2"]);
expect(filterAuditEntries(entries, { query: "inv-1" }).map((e) => e.id)).toEqual(["1"]);
expect(filterAuditEntries(entries, { from: 1500, to: 2500 }).map((e) => e.id)).toEqual(["3"]);
});

it("escapes CSV cells", () => {
const csv = auditEntriesToCsv([entries[2]]);
expect(csv.split("\n")[1]).toContain('"note, ""quoted"""');
});
});

describe("AuditLogViewer", () => {
it("renders entries and filters by search and category", () => {
render(<AuditLogViewer entries={entries} />);
expect(screen.getAllByRole("listitem")).toHaveLength(3);
fireEvent.change(screen.getByLabelText("Search audit log"), { target: { value: "signed" } });
expect(screen.getAllByRole("listitem")).toHaveLength(1);
fireEvent.change(screen.getByLabelText("Filter by category"), { target: { value: "payment" } });
expect(screen.getByText(/No audit entries/)).toBeInTheDocument();
});
});
31 changes: 31 additions & 0 deletions src/__tests__/CreatorMarketplace.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import CreatorMarketplace from "@/components/marketplace/CreatorMarketplace";
import { discoverCreators, listCategories, type MarketplaceCreator } from "@/lib/creatorMarketplace";

const creators: MarketplaceCreator[] = [
{ address: "GA", name: "Ada", category: "Design", tags: ["logo"], rating: 4.2, completedInvoices: 30, verified: true },
{ address: "GB", name: "Bo", category: "Music", tags: ["mixing"], rating: 4.9, completedInvoices: 5, verified: false },
{ address: "GC", name: "Cy", category: "Design", tags: ["ui"], rating: 3.5, completedInvoices: 50, verified: true },
];

describe("creatorMarketplace", () => {
it("sorts and filters creators", () => {
expect(discoverCreators(creators).map((c) => c.name)).toEqual(["Bo", "Ada", "Cy"]);
expect(discoverCreators(creators, { sort: "popular" }).map((c) => c.name)).toEqual(["Cy", "Ada", "Bo"]);
expect(discoverCreators(creators, { category: "Design", verifiedOnly: true, sort: "name" }).map((c) => c.name)).toEqual(["Ada", "Cy"]);
expect(discoverCreators(creators, { query: "MIX" }).map((c) => c.name)).toEqual(["Bo"]);
expect(listCategories(creators)).toEqual(["Design", "Music"]);
});
});

describe("CreatorMarketplace", () => {
it("renders creators with profile links and supports filtering", () => {
render(<CreatorMarketplace creators={creators} />);
expect(screen.getByRole("link", { name: "Ada" })).toHaveAttribute("href", "/creator/GA");
fireEvent.click(screen.getByLabelText("Verified only"));
expect(screen.queryByText("Bo")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Search creators"), { target: { value: "zzz" } });
expect(screen.getByText("No creators found.")).toBeInTheDocument();
});
});
39 changes: 39 additions & 0 deletions src/__tests__/InvoiceRecommendations.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import InvoiceRecommendations from "@/components/recommendations/InvoiceRecommendations";
import { recommendInvoices, type PastInvoice } from "@/lib/invoiceRecommendations";

const DAY = 86_400_000;
const history: PastInvoice[] = [
{ id: "1", title: "Rent", amount: 900, recipients: ["GA", "GB"], createdAt: 0 },
{ id: "2", title: "rent ", amount: 900, recipients: ["GA", "GB"], createdAt: 30 * DAY },
{ id: "3", title: "Dinner", amount: 60, recipients: ["GA"], createdAt: 40 * DAY },
];

describe("recommendInvoices", () => {
it("returns nothing for empty history", () => {
expect(recommendInvoices([])).toEqual([]);
});

it("suggests frequent recipients, typical amount and recurring invoices", () => {
const recs = recommendInvoices(history);
expect(recs[0]).toMatchObject({ type: "recipient", value: "GA", score: 1 });
expect(recs.find((r) => r.type === "amount")?.value).toBe("900.00");
expect(recs.find((r) => r.type === "recurring")?.label).toBe("Recurring every ~30 days");
expect(recommendInvoices(history, 2)).toHaveLength(2);
});
});

describe("InvoiceRecommendations", () => {
it("renders recommendations and applies one", () => {
const onApply = vi.fn();
render(<InvoiceRecommendations history={history} onApply={onApply} />);
fireEvent.click(screen.getAllByRole("button", { name: "Use" })[0]);
expect(onApply).toHaveBeenCalledWith(expect.objectContaining({ value: "GA" }));
});

it("shows empty state", () => {
render(<InvoiceRecommendations history={[]} />);
expect(screen.getByText(/Create a few invoices/)).toBeInTheDocument();
});
});
36 changes: 36 additions & 0 deletions src/__tests__/SuspiciousActivityAlerts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import SuspiciousActivityAlerts from "@/components/security/SuspiciousActivityAlerts";
import { detectSuspiciousActivity, type ActivityEvent } from "@/lib/suspiciousActivity";

const normal: ActivityEvent[] = [
{ id: "a", actor: "GA", amount: 10, timestamp: 0 },
{ id: "b", actor: "GB", amount: 12, timestamp: 100_000 },
{ id: "c", actor: "GC", amount: 11, timestamp: 200_000 },
];

describe("detectSuspiciousActivity", () => {
it("returns no alerts for normal activity", () => {
expect(detectSuspiciousActivity(normal)).toEqual([]);
});

it("flags velocity bursts, amount spikes and repeated amounts", () => {
const burst = Array.from({ length: 6 }, (_, i) => ({ id: `v${i}`, actor: "GX", amount: 5 + i, timestamp: i * 1000 }));
const repeats = Array.from({ length: 3 }, (_, i) => ({ id: `r${i}`, actor: "GR", amount: 7, timestamp: i * 600_000 }));
const spike = { id: "s", actor: "GS", amount: 10_000, timestamp: 0 };
const alerts = detectSuspiciousActivity([...normal, ...burst, ...repeats, spike]);
expect(alerts.map((a) => a.rule)).toEqual(["velocity", "amount_spike", "repeated_amount"]);
expect(alerts[0].eventIds).toHaveLength(6);
});
});

describe("SuspiciousActivityAlerts", () => {
it("shows empty state and dismisses alerts", () => {
const { rerender } = render(<SuspiciousActivityAlerts events={normal} />);
expect(screen.getByText("No suspicious activity detected.")).toBeInTheDocument();
rerender(<SuspiciousActivityAlerts events={[...normal, { id: "s", actor: "GS", amount: 9999, timestamp: 0 }]} />);
expect(screen.getByRole("alert")).toHaveTextContent(/medium risk/i);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
87 changes: 87 additions & 0 deletions src/components/audit/AuditLogViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use client";

import { useMemo, useState } from "react";
import {
auditEntriesToCsv,
filterAuditEntries,
type AuditCategory,
type AuditEntry,
} from "@/lib/auditLog";

const CATEGORIES: Array<AuditCategory | "all"> = ["all", "invoice", "payment", "auth", "settings", "admin"];

interface Props {
entries: AuditEntry[];
}

export default function AuditLogViewer({ entries }: Props) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState<AuditCategory | "all">("all");
const visible = useMemo(() => filterAuditEntries(entries, { query, category }), [entries, query, category]);

const exportCsv = () => {
const url = URL.createObjectURL(new Blob([auditEntriesToCsv(visible)], { type: "text/csv" }));
const a = document.createElement("a");
a.href = url;
a.download = "audit-log.csv";
a.click();
URL.revokeObjectURL(url);
};

return (
<section aria-label="Audit log" className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="search"
aria-label="Search audit log"
placeholder="Search actor, action, target…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<select
aria-label="Filter by category"
value={category}
onChange={(e) => setCategory(e.target.value as AuditCategory | "all")}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm capitalize"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<button
type="button"
onClick={exportCsv}
disabled={visible.length === 0}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
Export CSV
</button>
</div>

{visible.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-500">No audit entries match your filters.</p>
) : (
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200">
{visible.map((e) => (
<li key={e.id} className="flex flex-col gap-1 p-3 sm:flex-row sm:items-center sm:gap-4">
<time className="shrink-0 text-xs text-gray-500 sm:w-44" dateTime={new Date(e.timestamp).toISOString()}>
{new Date(e.timestamp).toLocaleString()}
</time>
<span className="w-fit rounded-full bg-gray-100 px-2 py-0.5 text-xs capitalize">{e.category}</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{e.action}</p>
{e.details && <p className="text-xs text-gray-600">{e.details}</p>}
</div>
<span className="truncate font-mono text-xs text-gray-500 sm:max-w-[10rem]" title={e.actor}>
{e.actor}
</span>
</li>
))}
</ul>
)}
</section>
);
}
94 changes: 94 additions & 0 deletions src/components/marketplace/CreatorMarketplace.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"use client";

import Link from "next/link";
import { useMemo, useState } from "react";
import {
discoverCreators,
listCategories,
type CreatorSort,
type MarketplaceCreator,
} from "@/lib/creatorMarketplace";

interface Props {
creators: MarketplaceCreator[];
}

export default function CreatorMarketplace({ creators }: Props) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("all");
const [sort, setSort] = useState<CreatorSort>("rating");
const [verifiedOnly, setVerifiedOnly] = useState(false);
const categories = useMemo(() => listCategories(creators), [creators]);
const results = useMemo(
() => discoverCreators(creators, { query, category, sort, verifiedOnly }),
[creators, query, category, sort, verifiedOnly],
);

return (
<section aria-label="Creator marketplace" className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<input
type="search"
aria-label="Search creators"
placeholder="Search by name or tag…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<select
aria-label="Category"
value={category}
onChange={(e) => setCategory(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="all">All categories</option>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select
aria-label="Sort by"
value={sort}
onChange={(e) => setSort(e.target.value as CreatorSort)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="rating">Top rated</option>
<option value="popular">Most popular</option>
<option value="name">Name</option>
</select>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={verifiedOnly} onChange={(e) => setVerifiedOnly(e.target.checked)} />
Verified only
</label>
</div>

{results.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-500">No creators found.</p>
) : (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{results.map((c) => (
<li key={c.address} className="rounded-lg border border-gray-200 p-4">
<Link href={`/creator/${c.address}`} className="font-semibold hover:underline">
{c.name}
</Link>
{c.verified && <span className="ml-2 text-xs text-green-600">✓ Verified</span>}
<p className="text-xs text-gray-500">{c.category}</p>
<p className="mt-2 text-sm">
★ {c.rating.toFixed(1)} · {c.completedInvoices} invoices
</p>
<div className="mt-2 flex flex-wrap gap-1">
{c.tags.map((t) => (
<span key={t} className="rounded-full bg-gray-100 px-2 py-0.5 text-xs">
{t}
</span>
))}
</div>
</li>
))}
</ul>
)}
</section>
);
}
42 changes: 42 additions & 0 deletions src/components/recommendations/InvoiceRecommendations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

import { useMemo } from "react";
import { recommendInvoices, type InvoiceRecommendation, type PastInvoice } from "@/lib/invoiceRecommendations";

interface Props {
history: PastInvoice[];
limit?: number;
onApply?: (rec: InvoiceRecommendation) => void;
}

export default function InvoiceRecommendations({ history, limit, onApply }: Props) {
const recs = useMemo(() => recommendInvoices(history, limit), [history, limit]);

if (recs.length === 0) {
return <p className="text-sm text-gray-500">Create a few invoices to get recommendations.</p>;
}

return (
<ul aria-label="Invoice recommendations" className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{recs.map((r) => (
<li key={r.id} className="flex items-center justify-between gap-3 rounded-lg border border-gray-200 p-3">
<div className="min-w-0">
<p className="text-xs text-gray-500">{r.label}</p>
<p className="truncate text-sm font-medium" title={r.value}>
{r.value}
</p>
</div>
{onApply && (
<button
type="button"
onClick={() => onApply(r)}
className="shrink-0 rounded-md bg-indigo-600 px-3 py-1 text-xs font-medium text-white"
>
Use
</button>
)}
</li>
))}
</ul>
);
}
Loading
Loading