From 0861663bea72057a3f8e7474aaaf4f05e9e76b02 Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 14:32:06 -0600 Subject: [PATCH] a18hvcryt: Delete the signed-in person's own post from its menu --- src/app/api/posts/[id]/route.ts | 20 +++++ src/app/api/session/route.ts | 1 + src/app/globals.css | 8 ++ src/components/App.tsx | 10 ++- src/components/Feed.tsx | 35 ++++++++- src/components/FeedView.tsx | 11 ++- src/components/PostCard.tsx | 45 ++++++++++- src/lib/auth-client.ts | 3 +- src/lib/delete-client.ts | 43 +++++++++++ src/lib/feed-model.ts | 9 +++ src/lib/minnect.ts | 25 ++++++- src/lib/post-delete.ts | 52 +++++++++++++ src/lib/session-store.ts | 2 + tests/delete.test.ts | 127 ++++++++++++++++++++++++++++++++ 14 files changed, 383 insertions(+), 8 deletions(-) create mode 100644 src/app/api/posts/[id]/route.ts create mode 100644 src/lib/delete-client.ts create mode 100644 src/lib/post-delete.ts create mode 100644 tests/delete.test.ts diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts new file mode 100644 index 0000000..b9ea408 --- /dev/null +++ b/src/app/api/posts/[id]/route.ts @@ -0,0 +1,20 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { SESSION_COOKIE } from "@/lib/cookie"; +import { deletePost } from "@/lib/post-delete"; + +// DELETE /api/posts/:id -> feed DELETE /api/v1/posts/{id} +// 401 { error: "sign_in" } without a session. Returns { ok: true } on success. + +export const runtime = "nodejs"; + +type Ctx = { params: Promise<{ id: string }> }; + +export async function DELETE(req: NextRequest, ctx: Ctx) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + const { id } = await ctx.params; + const result = await deletePost(sessionId, id); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts index 6f7ee4d..7b101d8 100644 --- a/src/app/api/session/route.ts +++ b/src/app/api/session/route.ts @@ -88,6 +88,7 @@ export async function GET(req: NextRequest) { name: session.person.name, email: session.person.email, pictureUrl: session.person.pictureUrl, + feedUserId: session.person.feedUserId ?? null, }); } diff --git a/src/app/globals.css b/src/app/globals.css index 08582dd..791fcb0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -61,3 +61,11 @@ button:disabled { opacity: .5; cursor: not-allowed; } .comment-form button { margin: 0; } .link-btn { background: transparent; color: inherit; text-decoration: underline; padding: 0; margin: 0; font-size: inherit; width: auto; } .comments-toggle { background: transparent; color: var(--grey); padding: 4px 8px; margin: 0; font-size: 14px; font-weight: 400; width: auto; } + +.post-menu { position: relative; margin-left: auto; } +.menu-btn { margin: 0; padding: 2px 10px; background: transparent; color: var(--grey); border-radius: 8px; font-size: 18px; line-height: 1; letter-spacing: 1px; } +.menu { position: absolute; right: 0; top: 100%; z-index: 5; display: flex; flex-direction: column; gap: 2px; min-width: 150px; padding: 6px; background: #fff; border: 1px solid #e3e3e3; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.12); } +.menu button { margin: 0; text-align: left; background: transparent; color: inherit; padding: 6px 8px; border-radius: 6px; font-size: 14px; font-weight: 400; } +.menu button:hover { background: #f4f4f4; } +.menu .menu-danger { color: #a8141b; } +.menu-ask { font-size: 13px; color: var(--grey); padding: 4px 8px; } diff --git a/src/components/App.tsx b/src/components/App.tsx index d2fb73c..2c0a05f 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -38,7 +38,13 @@ export function App() { ); } if (state.kind === "out") { - return setState({ kind: "in", person })} />; + return { + setState({ kind: "in", person }); + // POST /api/session keeps its shape; the feed user id comes from GET. + void loadSession().then((full) => { + if (full) setState({ kind: "in", person: full }); + }); + }} />; } return (
@@ -52,7 +58,7 @@ export function App() { {signingOut ? "Signing out..." : "Sign out"} - setState({ kind: "out" })} /> + setState({ kind: "out" })} />
); } diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 0660b5c..da0fe97 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -5,11 +5,12 @@ import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client"; import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client"; import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model"; import { PostError, publishAndConfirm } from "@/lib/post-client"; +import { DeleteError, sendDelete } from "@/lib/delete-client"; import { FeedView, type FeedStatus } from "./FeedView"; const POLL_MS = 45_000; -export function Feed({ onSignedOut }: { onSignedOut: () => void }) { +export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; feedUserId?: string | null }) { const [posts, setPosts] = useState([]); const [status, setStatus] = useState("loading"); const [nextCursor, setNextCursor] = useState(null); @@ -18,6 +19,8 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) { const [pending, setPending] = useState([]); const [likeBusy, setLikeBusy] = useState>(new Set()); const [likeErrors, setLikeErrors] = useState>(new Set()); + const [deleteBusy, setDeleteBusy] = useState>(new Set()); + const [deleteErrors, setDeleteErrors] = useState>(new Set()); const [draft, setDraft] = useState(""); const [posting, setPosting] = useState(false); const [postFailed, setPostFailed] = useState(false); @@ -156,6 +159,32 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) { [fail], ); + const removePost = useCallback( + async (post: CardPost) => { + const mark = (set: ReadonlySet, on: boolean) => { + const next = new Set(set); + if (on) next.add(post.key); + else next.delete(post.key); + return next; + }; + setDeleteBusy((s) => mark(s, true)); + setDeleteErrors((s) => mark(s, false)); + try { + // Removes the card only after the feed answered 200. + if (!post.id) throw new DeleteError(false); + await sendDelete(post.id); + setPosts((cur) => cur.filter((p) => p.key !== post.key)); + setPending((cur) => cur.filter((p) => p.key !== post.key)); + } catch (e) { + if (e instanceof DeleteError && e.signedOut) onSignedOut(); + setDeleteErrors((s) => mark(s, true)); + } finally { + setDeleteBusy((s) => mark(s, false)); + } + }, + [onSignedOut], + ); + const publish = useCallback(async () => { const text = draft.trim(); if (!text || posting) return; @@ -199,6 +228,10 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) { onToggleLike={(p) => void toggleLike(p)} likeBusy={likeBusy} likeErrors={likeErrors} + feedUserId={feedUserId} + onDelete={(p) => void removePost(p)} + deleteBusy={deleteBusy} + deleteErrors={deleteErrors} onSignedOut={onSignedOut} composer={{ value: draft, busy: posting, error: postFailed, onChange: changeDraft, onSubmit: () => void publish() }} sentinelRef={sentinel} diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx index 0046891..fbe02cd 100644 --- a/src/components/FeedView.tsx +++ b/src/components/FeedView.tsx @@ -1,5 +1,5 @@ import type { Ref } from "react"; -import type { CardPost } from "@/lib/feed-model"; +import { canDelete, type CardPost } from "@/lib/feed-model"; import { Composer, type ComposerProps } from "./Composer"; import { PostCard } from "./PostCard"; @@ -17,6 +17,11 @@ export interface FeedViewProps { likeBusy?: ReadonlySet; likeErrors?: ReadonlySet; onSignedOut?: () => void; + /** The signed-in person's feed user id; delete is offered only on their own posts. */ + feedUserId?: string | null; + onDelete?: (post: CardPost) => void; + deleteBusy?: ReadonlySet; + deleteErrors?: ReadonlySet; composer?: ComposerProps; sentinelRef?: Ref; now?: number; @@ -65,6 +70,10 @@ export function FeedView(p: FeedViewProps) { likeBusy={p.likeBusy?.has(post.key)} likeError={p.likeErrors?.has(post.key)} onSignedOut={p.onSignedOut} + canDelete={canDelete(post, p.feedUserId)} + onDelete={p.onDelete} + deleteBusy={p.deleteBusy?.has(post.key)} + deleteError={p.deleteErrors?.has(post.key)} />) )} {p.loadMoreFailed ? ( diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 5c9d130..e1f2c0f 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -12,6 +12,11 @@ export interface PostCardProps { likeBusy?: boolean; likeError?: boolean; onSignedOut?: () => void; + /** True only on the signed-in person's own post. */ + canDelete?: boolean; + onDelete?: (post: CardPost) => void; + deleteBusy?: boolean; + deleteError?: boolean; } function Heart({ filled }: { filled: boolean }) { @@ -24,8 +29,10 @@ function Heart({ filled }: { filled: boolean }) { ); } -export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut }: PostCardProps) { +export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) { const [open, setOpen] = useState(false); + const [menu, setMenu] = useState(false); + const [confirming, setConfirming] = useState(false); return (
@@ -41,7 +48,43 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne {post.authorName} + {canDelete && onDelete ? ( +
+ + {menu ? ( +
+ {confirming ? ( + <> + Delete this post? + + + + ) : ( + + )} +
+ ) : null} +
+ ) : null}
+ {deleteError ?

Could not delete the post.

: null} {post.text ?

{post.text}

: null} {post.media.map((m, i) => m.kind === "video" && m.hlsUrl ? ( diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 5614acc..5739982 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,7 +1,7 @@ // Browser-side calls to this app's own /api/session. No token, password or // session id is stored or logged here; the session lives in an httpOnly cookie. -export type Person = { name: string; email: string; pictureUrl: string | null }; +export type Person = { name: string; email: string; pictureUrl: string | null; feedUserId?: string }; export const WRONG_CREDENTIALS = "Email or password is incorrect."; export const GENERIC_FAILURE = "Could not sign you in. Try again."; @@ -16,6 +16,7 @@ function toPerson(body: unknown): Person | null { name: typeof r.name === "string" ? r.name : "", email: r.email, pictureUrl: typeof r.pictureUrl === "string" ? r.pictureUrl : null, + ...(typeof r.feedUserId === "string" && r.feedUserId ? { feedUserId: r.feedUserId } : {}), }; } diff --git a/src/lib/delete-client.ts b/src/lib/delete-client.ts new file mode 100644 index 0000000..b8059a8 --- /dev/null +++ b/src/lib/delete-client.ts @@ -0,0 +1,43 @@ +import type { CardPost } from "./feed-model"; + +// Browser-side delete call. + +export class DeleteError extends Error { + constructor(public readonly signedOut: boolean) { + super(signedOut ? "sign_in" : "delete_failed"); + } +} + +/** DELETE /api/posts/:id. Resolves on success; throws DeleteError otherwise. */ +export async function sendDelete(postId: string, fetchImpl: typeof fetch = fetch): Promise { + let res: Response; + try { + res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}`, { + method: "DELETE", + credentials: "same-origin", + }); + } catch { + throw new DeleteError(false); + } + if (res.status === 401) throw new DeleteError(true); + if (!res.ok) throw new DeleteError(false); +} + +/** The feed list after a successful delete: the card is gone. */ +export function withoutPost(posts: CardPost[], key: string): CardPost[] { + return posts.filter((p) => p.key !== key); +} + +/** + * Delete a post and return the new list. The card is removed only after the + * server answers 200; on any failure this throws and the caller keeps the list. + */ +export async function deleteAndRemove( + posts: CardPost[], + post: CardPost, + send: (id: string) => Promise = sendDelete, +): Promise { + if (!post.id) throw new DeleteError(false); + await send(post.id); + return withoutPost(posts, post.key); +} diff --git a/src/lib/feed-model.ts b/src/lib/feed-model.ts index 225c3a5..c34f006 100644 --- a/src/lib/feed-model.ts +++ b/src/lib/feed-model.ts @@ -12,6 +12,8 @@ export interface CardMedia { export interface CardPost { key: string; id: string | null; + /** The author's id on the feed, for matching against the signed-in feed user id. */ + authorId: string | null; authorName: string; authorPictureUrl: string | null; publishedAt: string | null; @@ -95,6 +97,8 @@ export function toCardPost(item: unknown, index = 0): CardPost | null { return { key: id ?? `${publishedAt ?? "unknown"}#${index}`, id, + authorId: + typeof author.id === "string" || typeof author.id === "number" ? String(author.id) : null, authorName: authorName(author), authorPictureUrl: str(author.profilePictureUrl), publishedAt, @@ -151,3 +155,8 @@ export function relativeTime(publishedAt: string | null, now: number = Date.now( if (d < 7) return `${d}d ago`; return new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } + +/** Delete is offered only on the signed-in person's own post. */ +export function canDelete(post: CardPost, feedUserId: string | null | undefined): boolean { + return !!feedUserId && !!post.id && post.authorId !== null && post.authorId === feedUserId; +} diff --git a/src/lib/minnect.ts b/src/lib/minnect.ts index e038d9d..0f4f45c 100644 --- a/src/lib/minnect.ts +++ b/src/lib/minnect.ts @@ -1,4 +1,4 @@ -import { PROFILES_BASE_URL } from "./env"; +import { FEED_BASE_URL, PROFILES_BASE_URL } from "./env"; import type { SessionPerson } from "./session-store"; // Talks to the Minnect profiles host to turn an email + password into the @@ -217,6 +217,23 @@ export async function revokeToken( } } +/** `GET /api/v1/auth/me` on the feed host. Returns data.id, or null on any failure. */ +async function fetchFeedUserId(feedToken: string, doFetch: typeof fetch): Promise { + try { + const res = await doFetch(`${FEED_BASE_URL}/api/v1/auth/me`, { + method: "GET", + headers: jsonHeaders(feedToken), + }); + if (res.status !== 200) return null; + const id = asRecord(asRecord(await parseJson(res)).data).id; + if (typeof id === "string" && id.length > 0) return id; + if (typeof id === "number" && Number.isFinite(id)) return String(id); + return null; + } catch { + return null; + } +} + /** * Runs the three-call sign-in: login -> auth/me -> feed-token exchange. * Returns the person to show and the tokens to store server-side. @@ -278,8 +295,12 @@ export async function loginToMinnect( options, ); + // 4. The person's id on the feed, used to decide who may delete a post. + // Best-effort: without it no delete control is offered. + const feedUserId = await fetchFeedUserId(feedAccessToken, doFetch); + return { - person: { userId, name, email: personEmail, pictureUrl }, + person: { userId, name, email: personEmail, pictureUrl, feedUserId }, tokens: { profileAccessToken, profileAccessTokenExpiresAt, diff --git a/src/lib/post-delete.ts b/src/lib/post-delete.ts new file mode 100644 index 0000000..ab1831f --- /dev/null +++ b/src/lib/post-delete.ts @@ -0,0 +1,52 @@ +import { FEED_BASE_URL } from "./env"; +import { ensureFreshSession, refreshSession } from "./session-refresh"; + +// Delete proxy: feed DELETE /api/v1/posts/{id} with the person's server-held +// feed token. The feed decides whether the caller may delete; this app also +// only offers the control on the person's own posts. One refresh-and-retry +// after a 401 token rejection. + +export type DeleteResult = + | { ok: true } + | { ok: false; status: 400 | 401 | 502; error: "sign_in" | "bad_post" | "delete_failed" }; + +interface DeleteOptions { + fetchImpl?: typeof fetch; + now?: number; +} + +const POST_ID = /^[A-Za-z0-9_-]{1,128}$/; + +function call(token: string, postId: string, doFetch: typeof fetch) { + return doFetch(`${FEED_BASE_URL}/api/v1/posts/${encodeURIComponent(postId)}`, { + method: "DELETE", + headers: { Accept: "application/json", Authorization: `Bearer ${token}` }, + }); +} + +export async function deletePost( + sessionId: string | undefined, + postId: string, + options: DeleteOptions = {}, +): Promise { + const signIn = { ok: false, status: 401, error: "sign_in" } as const; + const failed = { ok: false, status: 502, error: "delete_failed" } as const; + const doFetch = options.fetchImpl ?? fetch; + + let session = await ensureFreshSession(sessionId, options); + if (!session || !sessionId) return signIn; + if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" }; + + try { + let res = await call(session.feedAccessToken, postId, doFetch); + if (res.status === 401) { + session = await refreshSession(sessionId, options); + if (!session) return signIn; + res = await call(session.feedAccessToken, postId, doFetch); + if (res.status === 401) return signIn; + } + return res.status === 200 ? { ok: true } : failed; + } catch { + return failed; + } +} diff --git a/src/lib/session-store.ts b/src/lib/session-store.ts index 8ae967b..24d14f7 100644 --- a/src/lib/session-store.ts +++ b/src/lib/session-store.ts @@ -12,6 +12,8 @@ export interface SessionPerson { name: string; email: string; pictureUrl: string | null; + /** The person's id on the feed (feed GET /api/v1/auth/me data.id); null when unknown. */ + feedUserId?: string | null; } export interface SessionData { diff --git a/tests/delete.test.ts b/tests/delete.test.ts new file mode 100644 index 0000000..a8562f0 --- /dev/null +++ b/tests/delete.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { NextRequest } from "next/server"; +import { DELETE } from "@/app/api/posts/[id]/route"; +import { GET as getSession } from "@/app/api/session/route"; +import { FeedView } from "@/components/FeedView"; +import { toCardPost, toCardPosts, canDelete } from "@/lib/feed-model"; +import { DeleteError, deleteAndRemove, sendDelete } from "@/lib/delete-client"; +import { loginToMinnect } from "@/lib/minnect"; +import { createSession } from "@/lib/session-store"; + +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); +const item = (id: string, authorId: string) => ({ + type: "post", publishedAt: "2026-10-19T10:00:00Z", + post: { id, content: `text ${id}`, author: { id: authorId, displayName: "X" } }, +}); +const posts = () => toCardPosts([item("mine", "F1"), item("theirs", "F2")]); + +function view(feedUserId: string | null) { + return renderToStaticMarkup(createElement(FeedView, { + posts: posts(), status: "ready", loadingMore: false, loadMoreFailed: false, newCount: 0, + onRetry: () => {}, onShowNew: () => {}, feedUserId, onDelete: () => {}, + })); +} + +function session(feedUserId: string | null = "F1") { + return createSession({ + profileAccessToken: "p", profileAccessTokenExpiresAt: null, profileRefreshToken: "r", + feedAccessToken: "T", feedAccessTokenExpiresAt: null, + person: { userId: "u", name: "A", email: "a@b.co", pictureUrl: null, feedUserId }, createdAt: Date.now(), + }); +} + +async function withFetch(fn: typeof fetch, run: () => Promise) { + const orig = globalThis.fetch; + globalThis.fetch = fn; + try { return await run(); } finally { globalThis.fetch = orig; } +} + +describe("delete control visibility", () => { + it("own post has the menu; someone else's has none", () => { + const html = view("F1"); + expect(html.match(/aria-label="More actions"/g)).toHaveLength(1); + const cards = html.split('data-testid="post-card"'); + expect(cards[1]).toContain("More actions"); + expect(cards[2]).not.toContain("More actions"); + }); + + it("no feed user id means no delete control anywhere", () => { + expect(view(null)).not.toContain("More actions"); + expect(view("F9")).not.toContain("More actions"); + expect(canDelete(toCardPost(item("a", "F1"))!, undefined)).toBe(false); + }); +}); + +describe("DELETE /api/posts/:id", () => { + it("401 sign_in without a session", async () => { + const res = await DELETE(new NextRequest("http://localhost/api/posts/p1", { method: "DELETE" }), ctx("p1")); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "sign_in" }); + }); + + it("calls the feed DELETE and returns ok on 200", async () => { + const f = vi.fn(async () => new Response("{}", { status: 200 })); + const res = await withFetch(f as unknown as typeof fetch, () => + DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: { cookie: `mfb_session=${session()}` } }), ctx("p1"))); + expect(res.status).toBe(200); + const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit]; + expect(new URL(url).pathname).toBe("/api/v1/posts/p1"); + expect(init.method).toBe("DELETE"); + }); + + it("a feed failure is 502 delete_failed; a bad id never reaches the feed", async () => { + const h = { cookie: `mfb_session=${session()}` }; + const bad = vi.fn(async () => new Response("{}", { status: 500 })); + const r = await withFetch(bad as unknown as typeof fetch, () => + DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: h }), ctx("p1"))); + expect([r.status, await r.json()]).toEqual([502, { error: "delete_failed" }]); + const f = vi.fn(); + const r2 = await withFetch(f as unknown as typeof fetch, () => + DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: h }), ctx("../admin/x"))); + expect(r2.status).toBe(400); + expect(f).not.toHaveBeenCalled(); + }); +}); + +describe("removing the card", () => { + it("the card leaves the list after a 200", async () => { + const ok = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) as unknown as typeof fetch; + const list = posts(); + const next = await deleteAndRemove(list, list[0], (id) => sendDelete(id, ok)); + expect(next.map((p) => p.id)).toEqual(["theirs"]); + }); + + it("a failed delete throws and the caller keeps the card", async () => { + const fail = vi.fn(async () => new Response("{}", { status: 502 })) as unknown as typeof fetch; + const list = posts(); + await expect(deleteAndRemove(list, list[0], (id) => sendDelete(id, fail))).rejects.toBeInstanceOf(DeleteError); + expect(list).toHaveLength(2); + const out = vi.fn(async () => new Response("{}", { status: 401 })) as unknown as typeof fetch; + await expect(sendDelete("mine", out)).rejects.toMatchObject({ signedOut: true }); + }); +}); + +describe("feed user id", () => { + it("GET /api/session exposes feedUserId and no token", async () => { + const res = await getSession(new NextRequest("http://localhost/api/session", { headers: { cookie: `mfb_session=${session("F1")}` } })); + const body = await res.json(); + expect(body).toEqual({ name: "A", email: "a@b.co", pictureUrl: null, feedUserId: "F1" }); + }); + + it("sign-in captures data.id from the feed auth/me", async () => { + const f = vi.fn(async (url: string) => { + const u = String(url); + if (u.endsWith("/api/v4/auth/login")) return new Response(JSON.stringify({ data: { accessToken: "a", refreshToken: "r", expiresIn: 3600 } }), { status: 200 }); + if (u.endsWith("/api/v4/auth/me")) return new Response(JSON.stringify({ data: { userId: "u1", email: "a@b.co", firstName: "A" } }), { status: 200 }); + if (u.endsWith("/external-services/feed")) return new Response(JSON.stringify({ accessToken: "ft" }), { status: 200 }); + if (u.endsWith("/api/v1/auth/me")) return new Response(JSON.stringify({ data: { id: "F1" } }), { status: 200 }); + return new Response("{}", { status: 404 }); + }) as unknown as typeof fetch; + const r = await loginToMinnect("a@b.co", "pw", { fetchImpl: f }); + expect(r.person.feedUserId).toBe("F1"); + const down = vi.fn(async (url: string) => String(url).endsWith("/api/v1/auth/me") ? new Response("{}", { status: 500 }) : (f as unknown as (u: string) => Promise)(url)) as unknown as typeof fetch; + expect((await loginToMinnect("a@b.co", "pw", { fetchImpl: down })).person.feedUserId).toBeNull(); + }); +});