Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
107 commits
Select commit Hold shift + click to select a range
74e71aa
docs(review): Phase 1 survey findings + CHANGES log
holkexyz May 28, 2026
ead9d67
docs(changes): restructure CHANGES log to append-only with sentinel
holkexyz May 28, 2026
07e945e
fix(context): reject non-http attachment uris in resolveAttachment [b…
holkexyz May 28, 2026
1fc73e3
fix(metadata): point OG image to existing certs-hero asset [bug-002]
holkexyz May 28, 2026
5383a38
fix(groups): preserve InvalidSwap discriminator on BFF writes [bug-003]
holkexyz May 28, 2026
d576305
fix(profile): await in-flight avatar/banner upload before save [bug-004]
holkexyz May 28, 2026
fc61e7d
fix(project): resolve location strongRef so it displays [bug-005]
holkexyz May 28, 2026
4088805
fix(groups): correct audit result pill class allowlist [bug-006]
holkexyz May 28, 2026
18dee15
fix(endorsements): show rejected awards in owner Received inbox [bug-…
holkexyz May 28, 2026
a980cc2
fix(upload): surface blob upload errors and clear preview [bug-008]
holkexyz May 28, 2026
b102c82
fix(profile): self-preview banner pick in BannerUpload [bug-009]
holkexyz May 28, 2026
d129b75
fix(home-feed): render location MapPin in feed card with period [bug-…
holkexyz May 28, 2026
020ef46
fix(use-user-activities): guard loadMore generation + dedup URIs [bug…
holkexyz May 28, 2026
af20bdc
fix(xrpc): redact secrets in echoed 4xx upstream messages [risk-001]
holkexyz May 28, 2026
722bcf5
fix(groups/register): use logSafe on org-limit check failure [risk-002]
holkexyz May 28, 2026
2d6e42f
fix(groups): sanitize handle and validate email at register boundary …
holkexyz May 28, 2026
416554d
fix(profile): reuse minted location rkey on save retry [risk-003]
holkexyz May 28, 2026
fc7b7fb
fix(seo): disallow app-only routes in robots.ts [risk-006]
holkexyz May 28, 2026
e423835
fix(project): route save-conflict re-read through authFetch [risk-008]
holkexyz May 28, 2026
3299ced
refactor(feed): delete dead UserFeed component [quality-001]
holkexyz May 28, 2026
6492574
docs(changes): log quality-002 BLOCKED [quality-002]
holkexyz May 28, 2026
75895ea
refactor(cert-context): paginate fetchAllCertContext via cursor [qual…
holkexyz May 29, 2026
05a13a2
refactor(profile): drop dead EndorseShortcut + inert overlay [quality…
holkexyz May 29, 2026
9dea289
refactor(workspace): drop dead fetchOrganizationDids exports [quality…
holkexyz May 29, 2026
15b8510
refactor(feed): delete dead CertHeadlineByline component [quality-049]
holkexyz May 29, 2026
fafa782
refactor(feed): delete dead LocationCard component [quality-050]
holkexyz May 29, 2026
743d110
docs(feed): correct FeedLayout JSDoc to real consumers [quality-051]
holkexyz May 29, 2026
5b98701
chore(ci): add ci.yml running lint/tsc/test on PRs [quality-009]
holkexyz May 29, 2026
ed186b0
chore(tsconfig): add tsconfig.test.json + typecheck:test [quality-010]
holkexyz May 29, 2026
73850c3
chore(config): pin Node version via engines and .nvmrc [quality-018]
holkexyz May 29, 2026
9fbb21c
chore(scripts): add typecheck npm script [quality-019]
holkexyz May 29, 2026
2ffe2a3
chore(lint): drop no-op --ext flag from lint script [quality-020]
holkexyz May 29, 2026
a8f9c57
chore(lint): expand lint to eslint . with build-output ignores [quali…
holkexyz May 29, 2026
0519f5b
fix(scripts): unify audit dev-server port default to 3000 [quality-022]
holkexyz May 29, 2026
8c6b483
chore(gitignore): anchor core pattern to /core [quality-023]
holkexyz May 29, 2026
d43ed60
refactor(atproto): constrain saveWithSwap TDrafts to Partial<TSnapsho…
holkexyz May 29, 2026
376983e
refactor(swap): share one array-guarded shallowEqual [quality-026]
holkexyz May 29, 2026
dcd6756
fix(badges): sort missing createdAt def to end of canonical pick [qua…
holkexyz May 29, 2026
e762ab9
refactor(atproto): location.ts uses shared strict parseAtUri [quality…
holkexyz May 29, 2026
0954187
refactor(notifications): validate load-bearing edge fields [quality-029]
holkexyz May 29, 2026
c219186
fix(atproto): validate resolveHandle alsoKnownAs is a handle [quality…
holkexyz May 29, 2026
fde4b18
refactor(hooks): add module-level cache to useOrgProfile [quality-006]
holkexyz May 29, 2026
4529435
docs(changes): log quality-037 SKIPPED [quality-037]
holkexyz May 29, 2026
0386675
fix(home-feed): track INVALID_CURSOR recovery controller [quality-031]
holkexyz May 29, 2026
e45219b
fix(hooks): abort focus-revalidate fetches via ref'd controller [qual…
holkexyz May 29, 2026
542fe29
docs(changes): log quality-033 BLOCKED [quality-033]
holkexyz May 29, 2026
1abb7da
refactor(hooks): read live lists in useEndorsementLists mutations [qu…
holkexyz May 29, 2026
f5a19a8
refactor(explore): tie loadMore fetch to generation signal [quality-035]
holkexyz May 29, 2026
d25a3d3
fix(profile): revoke promoted avatar/banner blob URL after save [qual…
holkexyz May 29, 2026
91f29bc
fix(hooks): usePendingAwardsCount returns null when logged out [quali…
holkexyz May 29, 2026
763d9d8
refactor(hooks): reset bottom-sheet viewport styles on cleanup [quali…
holkexyz May 29, 2026
f726a82
fix(hooks): clear bottom-sheet dismiss timeout on unmount [quality-040]
holkexyz May 29, 2026
1a3933c
fix(hooks): clear in-flight dedupe on useOrgMarker refresh [quality-041]
holkexyz May 29, 2026
fe30617
refactor(profile): extract shared PersonCard + name-cache hook [quali…
holkexyz May 29, 2026
87b36e5
fix(profile-lists): show fallback row when a project fails to load [q…
holkexyz May 29, 2026
9d8cdd5
refactor(profile): use useClickOutsideClose for endorsement dropdowns…
holkexyz May 29, 2026
515a1c2
docs(changes): log quality-045 BLOCKED [quality-045]
holkexyz May 29, 2026
6ead1da
fix(profile): stable createdAt sort in endorsement lists [quality-046]
holkexyz May 29, 2026
52c6ee0
refactor(profile-lists): use row index for checkbox aria-label [quali…
holkexyz May 29, 2026
8079ad2
refactor(profile): keep optimistic endorse on list-append failure [qu…
holkexyz May 29, 2026
4edb194
test(sanitize): add unit tests for stripInvisible/sanitizeEmail/sanit…
holkexyz May 29, 2026
f1ec9f8
refactor(search-actors): route catch log through logSafe [quality-014]
holkexyz May 29, 2026
aaa4ef1
refactor(notifications): drop response body from upstream error log […
holkexyz May 29, 2026
18c48fd
fix(xrpc): clamp upstream status to valid HTTP range [quality-016]
holkexyz May 29, 2026
d573291
fix(indexer): enforce 32KB body cap in bytes not UTF-16 units [qualit…
holkexyz May 29, 2026
9266ad8
refactor(popover): use --z-popover token instead of z-[40] [quality-008]
holkexyz May 29, 2026
f66c4f1
refactor(onboarding): revoke step-profile preview object URLs [qualit…
holkexyz May 29, 2026
f1d47db
fix(feed): reset imageFailed when ActivityCard imageUrl changes [qual…
holkexyz May 29, 2026
7e376fc
refactor(feed): memoize ActivityCard to avoid loadMore re-renders [qu…
holkexyz May 29, 2026
38245a2
refactor(right-rail): key news images by thumb URL not index [quality…
holkexyz May 29, 2026
6fbdab9
refactor(feed): make expanded locations map height viewport-relative …
holkexyz May 29, 2026
8a24feb
fix(auth): match safeRedirect http: gate to module's !==production [q…
holkexyz May 29, 2026
ea0a318
docs(auth): note swallowed deleteSession is not session fixation [qua…
holkexyz May 29, 2026
3479605
docs(csrf): document deliberate missing-Origin-AND-Referer reject [qu…
holkexyz May 29, 2026
ea16a22
docs(agents): correct ALLOWED_WRITE_COLLECTIONS count to eleven [qual…
holkexyz May 29, 2026
d4b05fb
refactor(utils): extract countGraphemes helper [quality-056-cert-5]
holkexyz May 29, 2026
657dd36
refactor(project): extract own-certs quick-pick into useOwnCerts [qua…
holkexyz May 29, 2026
1aa685a
docs(project): document activity-only meta reads in project-detail [q…
holkexyz May 29, 2026
c3cb71e
refactor(map): drop dead theme reactivity in base tiles [quality-056-…
holkexyz May 29, 2026
d684e3c
refactor(geocode): delete dead forwardGeocode export [quality-056-map-4]
holkexyz May 29, 2026
b4b151b
refactor(map): tokenize leaflet embed bg; justify JS hex [quality-056…
holkexyz May 29, 2026
d913f1f
refactor(ui): tokenize ProviderRedirectOverlay z-index [quality-056-u…
holkexyz May 29, 2026
bd27181
fix(ui): skeleton text variant aria-hidden + honor width [quality-056…
holkexyz May 29, 2026
576b2b9
fix(ui): hide icon child while loading + default button type [quality…
holkexyz May 29, 2026
ea35ea2
refactor(explore): delete dead ?attrs= filter never set by any UI [qu…
holkexyz May 29, 2026
f685bf8
fix(explore): add aria-haspopup to sort + quality popover triggers [q…
holkexyz May 29, 2026
54321da
refactor(groups): use ConfirmDialog for Leave Group modal [quality-05…
holkexyz May 29, 2026
5c86eda
refactor(groups): delete dead AddOrgModal + MembershipSyncModal [qual…
holkexyz May 29, 2026
672549c
docs(changes): log quality-056-groups-5 SKIPPED [quality-056-groups-5]
holkexyz May 29, 2026
34f6954
fix(test): use renderHook in safe-redirect test to clear lint error […
holkexyz May 29, 2026
bcb4f20
fix(groups): re-stage only failed members on partial add [quality-056…
holkexyz May 29, 2026
561caf9
docs(changes): log quality-056-profile-edit-2 SKIPPED [quality-056-pr…
holkexyz May 29, 2026
e234749
test(profile): link edit-form inputs to error text via aria-described…
holkexyz May 29, 2026
b2c5c45
refactor(profile): derive BannerUpload label from displayed image [qu…
holkexyz May 29, 2026
aaa0869
refactor(profile): tokenize avatar overlay colors [quality-056-profil…
holkexyz May 29, 2026
0d55605
refactor(landing): delete dead orbiting-logos component [quality-056-…
holkexyz May 29, 2026
abb79c6
refactor(legal): theme-aware link/text tokens for dark mode [quality-…
holkexyz May 29, 2026
6adb271
refactor(global-error): radius 6px to 2px + token comment [quality-05…
holkexyz May 29, 2026
0ba95a2
docs(agents): sync §2 tech-stack versions with package.json [quality-…
holkexyz May 29, 2026
9f75429
refactor(groups): derive role allowlists from one ORG_ROLES source [q…
holkexyz May 29, 2026
acd6447
perf(groups/register): early-exit member walk on self-added hit [qual…
holkexyz May 29, 2026
a2d25d9
refactor(notifications): use shared truncateDid + getInitials [qualit…
holkexyz May 29, 2026
b5d5dd3
refactor(labeller): drop dead exports pickKnownLabel/FilterValue/ALL_…
holkexyz May 29, 2026
74cd8ce
refactor(lib): drop dead exports getBlobRefLinkFromBlob/clearRecently…
holkexyz May 29, 2026
ff0a189
test(utils): cover did.ts isValidDid/isDid validators [quality-056-ut…
holkexyz May 29, 2026
317c2f6
test(utils): cover bounded-cache, format-date, ip, recently-viewed [q…
holkexyz May 29, 2026
abac1ee
docs(changes): finalize Phase 2 summary, held list, and follow-ups
holkexyz May 29, 2026
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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: CI

on:
pull_request:
branches:
- staging
- main

jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.9.0'
cache: npm

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Type check
run: npx tsc --noEmit

- name: Test
run: npm test
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
core
/core
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
20.9.0
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ Certified is a passwordless identity platform built on **AT Protocol** (atproto)
| React | 19.x |
| Language | TypeScript 5 (strict, `paths: { "@/*": ["./src/*"] }`) |
| Styling | Tailwind CSS 3.4 (utilities only) + custom CSS in `globals.css` (BEM-like) |
| Atproto SDK | `@atproto/api` 0.19, `@atproto/oauth-client-node` 0.3, `@atproto/jwk-jose` 0.1 (`@atproto/oauth-client` 0.6 pulled in transitively) |
| Theming | `next-themes` 0.4 (light/dark via `data-theme` on `<html>`) |
| Atproto SDK | `@atproto/api` 0.13, `@atproto/oauth-client-node` 0.3, `@atproto/jwk-jose` 0.1 (`@atproto/oauth-client` 0.6 pulled in transitively) |
| Rich text | `@tiptap/react` 3.x (+ `starter-kit`, `extension-link`, `extension-placeholder`, `pm`) |
| Maps | `leaflet` 1.9 + `react-leaflet` 5.x |
| Session/State store | Upstash Redis (`@upstash/redis`) — REST-based, serverless-safe |
| Server actions | None — all server work is in route handlers (`src/app/api/**`) |
| Wallets | `wagmi` 2.x + `viem` 2.x + `@tanstack/react-query` (mounted only on `/settings/wallet`) |
Expand Down Expand Up @@ -642,7 +645,7 @@ These rules are mandatory. Treat any deviation as a regression.
6. **Sanitize input twice** — client AND server (defense in depth). Use `stripInvisible`, `sanitizeEmail`, `sanitizeHandle` from `src/lib/utils/sanitize.ts`. The regex is `/[​-‏
- ⁠-­͏؜᠎]/g`.
7. **Sanitize 5xx errors.** Never echo `err.message` from upstream PDS errors when status ≥ 500 — return `"Internal server error"` (or a route-specific generic). The XRPC proxy and `/api/groups/register` both do this; copy the pattern. (4xx errors *can* echo upstream messages — those are usually validation errors a user can act on.)
8. **Repo ownership on writes** — for `createRecord`/`putRecord`/`deleteRecord`, `body.repo` must equal the session DID. Cross-repo writes are 403.
9. **Collection allowlist** — only the four `ALLOWED_WRITE_COLLECTIONS` can be written through the XRPC proxy. Add to that array consciously, not implicitly.
9. **Collection allowlist** — only the eleven `ALLOWED_WRITE_COLLECTIONS` can be written through the XRPC proxy. Add to that array consciously, not implicitly.
10. **Blob limits** — 4 MB cap (image MIME types only) on `/api/xrpc/[...method]` for `uploadBlob`; 5 MB on the group blob route. Both check `Content-Length` and the actual buffer size. Vercel has a hard ~4.5 MB body cap that constrains the XRPC proxy.
11. **Service-auth tokens are short-lived and per-LXM** — `getServiceAuthToken(agent, lxm)` issues a token bound to a single method. Don't cache or reuse.

Expand Down
677 changes: 677 additions & 0 deletions CHANGES.md

Large diffs are not rendered by default.

819 changes: 819 additions & 0 deletions REVIEW.md

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,19 @@

import nextConfig from "eslint-config-next/core-web-vitals";

export default [
// Now that `npm run lint` runs `eslint .` (not just `src/`), the ignore
// list must cover build output at any depth — `.claude/worktrees/*` holds
// nested checkouts with their own `.next/` chunks, so a bare `.next/`
// (root-anchored in flat config) would let ESLint lint generated bundles.
const config = [
...nextConfig,
{
ignores: [".next/", "node_modules/", "coverage/"],
ignores: [
"**/.next/",
"**/node_modules/",
"**/coverage/",
".claude/",
],
},
{
rules: {
Expand All @@ -33,3 +42,5 @@ export default [
},
},
];

export default config;
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@
"name": "certified-app",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=20.9.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint src/ --ext .ts,.tsx",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"typecheck:test": "tsc --noEmit -p tsconfig.test.json"
},
"dependencies": {
"@atproto/api": "^0.13.20",
Expand Down
2 changes: 1 addition & 1 deletion scripts/audit-screenshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..");
const OUT = join(ROOT, "docs/design-audit/screenshots");

const BASE = process.env.BASE || "http://localhost:3001";
const BASE = process.env.BASE || "http://localhost:3000";

const SURFACES = [
{ name: "01-root", url: "/", note: "Root entry point" },
Expand Down
8 changes: 7 additions & 1 deletion scripts/capture-divergence-sheet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..");
const SHEET = "file://" + join(ROOT, "docs/design-audit/divergence-sheet.html");
// This sheet is a static file, not a dev-server route. BASE, when set, points
// the capture at a served origin; the file:// path is the default fallback so
// the script keeps working with no dev server running. (process.env.BASE is
// the same knob the other audit scripts read; default origin is :3000 there.)
const SHEET = process.env.BASE
? process.env.BASE + "/docs/design-audit/divergence-sheet.html"
: "file://" + join(ROOT, "docs/design-audit/divergence-sheet.html");
const OUT = join(ROOT, "docs/design-audit/divergence");

await mkdir(OUT, { recursive: true });
Expand Down
7 changes: 4 additions & 3 deletions scripts/recapture-landing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = join(__dirname, "..", "docs/design-consolidation/after");
const BASE = process.env.BASE || "http://localhost:3000";
await mkdir(OUT, { recursive: true });

const browser = await chromium.launch();
Expand All @@ -18,15 +19,15 @@ for (const theme of ["light", "dark"]) {
try { localStorage.setItem("theme", t); } catch {}
}, theme);
const page = await ctx.newPage();
await page.goto("http://localhost:3000/welcome", { waitUntil: "domcontentloaded" });
await page.goto(BASE + "/welcome", { waitUntil: "domcontentloaded" });
await page.waitForTimeout(1000);
await page.screenshot({ path: join(OUT, `landing_${theme}.png`), fullPage: true });
console.log(`✓ landing ${theme}`);
// Also capture /apps and /privacy as additional dark-mode tests.
await page.goto("http://localhost:3000/apps", { waitUntil: "domcontentloaded" });
await page.goto(BASE + "/apps", { waitUntil: "domcontentloaded" });
await page.waitForTimeout(600);
await page.screenshot({ path: join(OUT, `apps_${theme}.png`), fullPage: true });
await page.goto("http://localhost:3000/privacy", { waitUntil: "domcontentloaded" });
await page.goto(BASE + "/privacy", { waitUntil: "domcontentloaded" });
await page.waitForTimeout(600);
await page.screenshot({ path: join(OUT, `privacy_${theme}.png`), fullPage: true });
await ctx.close();
Expand Down
86 changes: 86 additions & 0 deletions src/app/__tests__/create-image-upload-error.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"

// --- Module mocks -----------------------------------------------------
// CreatePage pulls in auth/org contexts, next/navigation, navbar title,
// author info, and the blob uploader. Stub everything that isn't under
// test so the authenticated form mounts, then drive the image picker.

vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ did: "did:plc:me", isAuthenticated: true, isLoading: false }),
}))

vi.mock("@/lib/groups/org-context", () => ({
useOrg: () => ({ activeOrg: null }),
}))

vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}))

vi.mock("@/lib/navbar-context", () => ({
usePageTitle: () => {},
}))

vi.mock("@/hooks/use-author-info", () => ({
useAuthorInfo: () => ({ info: null }),
}))

// authFetch is used for the rights dropdown load — keep it inert so the
// effect resolves to an empty list without a real network call.
vi.mock("@/lib/auth/fetch", () => ({
authFetch: vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ records: [] }),
}),
}))

// The unit under test: uploadBlob rejects, simulating a failed blob
// write. The page must surface the error and clear the dangling preview.
const uploadBlob = vi.fn()
vi.mock("@/lib/atproto/profile", () => ({
uploadBlob: (...args: unknown[]) => uploadBlob(...args),
}))

import CreatePage from "../create/page"

beforeEach(() => {
cleanup()
uploadBlob.mockReset()
// jsdom doesn't implement object URLs — stub so the preview logic runs.
// @ts-expect-error — test shim
URL.createObjectURL = vi.fn(() => "blob:preview-url")
// @ts-expect-error — test shim
URL.revokeObjectURL = vi.fn()
})

afterEach(() => {
cleanup()
})

describe("CreatePage image upload failure", () => {
it("surfaces an error and clears the preview when uploadBlob rejects", async () => {
uploadBlob.mockRejectedValue(new Error("blob write failed"))

const { container } = render(<CreatePage />)

const input = container.querySelector(
"input.image-edit-overlay__input",
) as HTMLInputElement
expect(input).toBeTruthy()

const file = new File(["x"], "hero.png", { type: "image/png" })
fireEvent.change(input, { target: { files: [file] } })

// The error message surfaces in the page's role="alert" region.
expect(await screen.findByText("blob write failed")).toBeTruthy()

// The optimistic preview must be cleared — no <img> left behind that
// would let the user publish a cert without the previewed image.
await waitFor(() => {
expect(
container.querySelector("img.cert-detail__image-img"),
).toBeNull()
})
})
})
127 changes: 127 additions & 0 deletions src/app/__tests__/endorsements-received-rejected.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { render, screen, cleanup, waitFor } from "@testing-library/react"
import type { ReceivedEndorsement } from "@/hooks/use-received-endorsements"

// --- Module mocks -----------------------------------------------------
// EndorsementsPage pulls in auth, navbar/navigation contexts, the
// given-endorsements hook, the own-response-states hook, and per-row
// author-info. None of those are under test here — stub them to inert
// defaults so the page mounts on the Received tab and we can assert
// whether a rejected endorsement row is rendered.
//
// The one mock that carries the assertion is `useReceivedEndorsements`:
// it mirrors the real hook's `includeRejected` contract (rejected
// awards are stripped unless the caller opts in). The page is the
// owner's own management inbox, so the fix is for the page to pass
// `{ includeRejected: true }`; before the fix it passes nothing and
// the rejected row is filtered out.

const DID = "did:plc:owner"

const REJECTED_ENDORSEMENT: ReceivedEndorsement = {
uri: "at://did:plc:issuer/app.certified.badge.award/rejected1",
cid: "bafyrejected",
issuerDid: "did:plc:issuer",
createdAt: "2025-01-01T00:00:00.000Z",
note: "Rejected endorsement note",
responseState: "rejected",
}

vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ did: DID, isAuthenticated: true }),
}))

vi.mock("@/lib/navbar-context", () => ({
usePageTitle: () => undefined,
}))

vi.mock("next/navigation", () => ({
usePathname: () => "/endorsements",
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
useSearchParams: () => new URLSearchParams("tab=received"),
}))

vi.mock("@/hooks/use-endorsements", () => ({
useGivenEndorsements: () => ({
endorsements: [],
isLoading: false,
error: null,
refetch: vi.fn(async () => undefined),
}),
}))

// Mirror the real hook's `includeRejected` filter: rejected awards are
// only returned when the caller opts in. This is the exact behavior the
// fix exercises from the page's call site.
const receivedHook = vi.fn(
(
_profileDid: string | null,
opts?: { includeRejected?: boolean },
) => {
const all = [REJECTED_ENDORSEMENT]
const endorsements = opts?.includeRejected
? all
: all.filter((e) => e.responseState !== "rejected")
return { endorsements, isLoading: false, error: null }
},
)

vi.mock("@/hooks/use-received-endorsements", () => ({
useReceivedEndorsements: (
profileDid: string | null,
opts?: { includeRejected?: boolean },
) => receivedHook(profileDid, opts),
}))

vi.mock("@/hooks/use-own-response-states", () => ({
useOwnResponseStates: () => ({
resolve: () => ({ state: "rejected" as const }),
responses: [],
isLoading: false,
invalidate: vi.fn(),
refetch: vi.fn(async () => undefined),
}),
}))

vi.mock("@/hooks/use-author-info", () => ({
useAuthorInfo: () => ({
info: { did: "did:plc:issuer", handle: "issuer.test", displayName: "Issuer Name", avatarUrl: null },
isLoading: false,
error: null,
}),
}))

vi.mock("@/lib/atproto/badges", () => ({
deleteEndorsementAward: vi.fn(async () => undefined),
}))

import EndorsementsPage from "../endorsements/page"

beforeEach(() => {
cleanup()
receivedHook.mockClear()
})

afterEach(() => {
cleanup()
})

describe("EndorsementsPage Received tab — rejected endorsements", () => {
it("requests rejected endorsements and renders the rejected row (owner inbox)", async () => {
render(<EndorsementsPage />)

// The owner's own inbox must opt into seeing rejected awards so the
// user can review (and un-reject) them — §22.21 privacy is preserved
// because this is the owner viewing their own inbox.
await waitFor(() => {
expect(receivedHook).toHaveBeenCalledWith(
DID,
expect.objectContaining({ includeRejected: true }),
)
})

// And the rejected row actually renders.
expect(await screen.findByText("Issuer Name")).toBeTruthy()
expect(screen.getByText("Rejected endorsement note")).toBeTruthy()
})
})
Loading