a18hvcryt: Delete the signed-in person's own post from its menu

This commit is contained in:
2026-10-19 14:32:06 -06:00
parent 5a33546af2
commit 0861663bea
14 changed files with 383 additions and 8 deletions
+20
View File
@@ -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 });
}
+1
View File
@@ -88,6 +88,7 @@ export async function GET(req: NextRequest) {
name: session.person.name, name: session.person.name,
email: session.person.email, email: session.person.email,
pictureUrl: session.person.pictureUrl, pictureUrl: session.person.pictureUrl,
feedUserId: session.person.feedUserId ?? null,
}); });
} }
+8
View File
@@ -61,3 +61,11 @@ button:disabled { opacity: .5; cursor: not-allowed; }
.comment-form button { margin: 0; } .comment-form button { margin: 0; }
.link-btn { background: transparent; color: inherit; text-decoration: underline; padding: 0; margin: 0; font-size: inherit; width: auto; } .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; } .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; }
+8 -2
View File
@@ -38,7 +38,13 @@ export function App() {
); );
} }
if (state.kind === "out") { if (state.kind === "out") {
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />; return <SignInForm onSignedIn={(person) => {
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 ( return (
<main className="shell"> <main className="shell">
@@ -52,7 +58,7 @@ export function App() {
{signingOut ? "Signing out..." : "Sign out"} {signingOut ? "Signing out..." : "Sign out"}
</button> </button>
</header> </header>
<Feed onSignedOut={() => setState({ kind: "out" })} /> <Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
</main> </main>
); );
} }
+34 -1
View File
@@ -5,11 +5,12 @@ import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client"; import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model"; import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model";
import { PostError, publishAndConfirm } from "@/lib/post-client"; import { PostError, publishAndConfirm } from "@/lib/post-client";
import { DeleteError, sendDelete } from "@/lib/delete-client";
import { FeedView, type FeedStatus } from "./FeedView"; import { FeedView, type FeedStatus } from "./FeedView";
const POLL_MS = 45_000; 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<CardPost[]>([]); const [posts, setPosts] = useState<CardPost[]>([]);
const [status, setStatus] = useState<FeedStatus>("loading"); const [status, setStatus] = useState<FeedStatus>("loading");
const [nextCursor, setNextCursor] = useState<string | null>(null); const [nextCursor, setNextCursor] = useState<string | null>(null);
@@ -18,6 +19,8 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
const [pending, setPending] = useState<CardPost[]>([]); const [pending, setPending] = useState<CardPost[]>([]);
const [likeBusy, setLikeBusy] = useState<ReadonlySet<string>>(new Set()); const [likeBusy, setLikeBusy] = useState<ReadonlySet<string>>(new Set());
const [likeErrors, setLikeErrors] = useState<ReadonlySet<string>>(new Set()); const [likeErrors, setLikeErrors] = useState<ReadonlySet<string>>(new Set());
const [deleteBusy, setDeleteBusy] = useState<ReadonlySet<string>>(new Set());
const [deleteErrors, setDeleteErrors] = useState<ReadonlySet<string>>(new Set());
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [posting, setPosting] = useState(false); const [posting, setPosting] = useState(false);
const [postFailed, setPostFailed] = useState(false); const [postFailed, setPostFailed] = useState(false);
@@ -156,6 +159,32 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
[fail], [fail],
); );
const removePost = useCallback(
async (post: CardPost) => {
const mark = (set: ReadonlySet<string>, 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 publish = useCallback(async () => {
const text = draft.trim(); const text = draft.trim();
if (!text || posting) return; if (!text || posting) return;
@@ -199,6 +228,10 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
onToggleLike={(p) => void toggleLike(p)} onToggleLike={(p) => void toggleLike(p)}
likeBusy={likeBusy} likeBusy={likeBusy}
likeErrors={likeErrors} likeErrors={likeErrors}
feedUserId={feedUserId}
onDelete={(p) => void removePost(p)}
deleteBusy={deleteBusy}
deleteErrors={deleteErrors}
onSignedOut={onSignedOut} onSignedOut={onSignedOut}
composer={{ value: draft, busy: posting, error: postFailed, onChange: changeDraft, onSubmit: () => void publish() }} composer={{ value: draft, busy: posting, error: postFailed, onChange: changeDraft, onSubmit: () => void publish() }}
sentinelRef={sentinel} sentinelRef={sentinel}
+10 -1
View File
@@ -1,5 +1,5 @@
import type { Ref } from "react"; 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 { Composer, type ComposerProps } from "./Composer";
import { PostCard } from "./PostCard"; import { PostCard } from "./PostCard";
@@ -17,6 +17,11 @@ export interface FeedViewProps {
likeBusy?: ReadonlySet<string>; likeBusy?: ReadonlySet<string>;
likeErrors?: ReadonlySet<string>; likeErrors?: ReadonlySet<string>;
onSignedOut?: () => void; 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<string>;
deleteErrors?: ReadonlySet<string>;
composer?: ComposerProps; composer?: ComposerProps;
sentinelRef?: Ref<HTMLDivElement>; sentinelRef?: Ref<HTMLDivElement>;
now?: number; now?: number;
@@ -65,6 +70,10 @@ export function FeedView(p: FeedViewProps) {
likeBusy={p.likeBusy?.has(post.key)} likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)} likeError={p.likeErrors?.has(post.key)}
onSignedOut={p.onSignedOut} 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 ? ( {p.loadMoreFailed ? (
+44 -1
View File
@@ -12,6 +12,11 @@ export interface PostCardProps {
likeBusy?: boolean; likeBusy?: boolean;
likeError?: boolean; likeError?: boolean;
onSignedOut?: () => void; 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 }) { 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 [open, setOpen] = useState(false);
const [menu, setMenu] = useState(false);
const [confirming, setConfirming] = useState(false);
return ( return (
<article className="post" data-testid="post-card"> <article className="post" data-testid="post-card">
<header className="post-head"> <header className="post-head">
@@ -41,7 +48,43 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
<strong>{post.authorName}</strong> <strong>{post.authorName}</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time> <time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
</div> </div>
{canDelete && onDelete ? (
<div className="post-menu">
<button
type="button"
className="menu-btn"
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={menu}
disabled={deleteBusy}
onClick={() => { setMenu((m) => !m); setConfirming(false); }}
>
...
</button>
{menu ? (
<div className="menu" role="menu">
{confirming ? (
<>
<span className="menu-ask">Delete this post?</span>
<button type="button" role="menuitem" className="menu-danger" disabled={deleteBusy}
onClick={() => { setMenu(false); setConfirming(false); onDelete(post); }}>
Confirm delete
</button>
<button type="button" role="menuitem" onClick={() => { setConfirming(false); setMenu(false); }}>
Cancel
</button>
</>
) : (
<button type="button" role="menuitem" className="menu-danger" onClick={() => setConfirming(true)}>
Delete post
</button>
)}
</div>
) : null}
</div>
) : null}
</header> </header>
{deleteError ? <p className="like-error" role="alert">Could not delete the post.</p> : null}
{post.text ? <p className="post-text">{post.text}</p> : null} {post.text ? <p className="post-text">{post.text}</p> : null}
{post.media.map((m, i) => {post.media.map((m, i) =>
m.kind === "video" && m.hlsUrl ? ( m.kind === "video" && m.hlsUrl ? (
+2 -1
View File
@@ -1,7 +1,7 @@
// Browser-side calls to this app's own /api/session. No token, password or // 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. // 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 WRONG_CREDENTIALS = "Email or password is incorrect.";
export const GENERIC_FAILURE = "Could not sign you in. Try again."; 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 : "", name: typeof r.name === "string" ? r.name : "",
email: r.email, email: r.email,
pictureUrl: typeof r.pictureUrl === "string" ? r.pictureUrl : null, pictureUrl: typeof r.pictureUrl === "string" ? r.pictureUrl : null,
...(typeof r.feedUserId === "string" && r.feedUserId ? { feedUserId: r.feedUserId } : {}),
}; };
} }
+43
View File
@@ -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<void> {
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<void> = sendDelete,
): Promise<CardPost[]> {
if (!post.id) throw new DeleteError(false);
await send(post.id);
return withoutPost(posts, post.key);
}
+9
View File
@@ -12,6 +12,8 @@ export interface CardMedia {
export interface CardPost { export interface CardPost {
key: string; key: string;
id: string | null; id: string | null;
/** The author's id on the feed, for matching against the signed-in feed user id. */
authorId: string | null;
authorName: string; authorName: string;
authorPictureUrl: string | null; authorPictureUrl: string | null;
publishedAt: string | null; publishedAt: string | null;
@@ -95,6 +97,8 @@ export function toCardPost(item: unknown, index = 0): CardPost | null {
return { return {
key: id ?? `${publishedAt ?? "unknown"}#${index}`, key: id ?? `${publishedAt ?? "unknown"}#${index}`,
id, id,
authorId:
typeof author.id === "string" || typeof author.id === "number" ? String(author.id) : null,
authorName: authorName(author), authorName: authorName(author),
authorPictureUrl: str(author.profilePictureUrl), authorPictureUrl: str(author.profilePictureUrl),
publishedAt, publishedAt,
@@ -151,3 +155,8 @@ export function relativeTime(publishedAt: string | null, now: number = Date.now(
if (d < 7) return `${d}d ago`; if (d < 7) return `${d}d ago`;
return new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); 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;
}
+23 -2
View File
@@ -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"; import type { SessionPerson } from "./session-store";
// Talks to the Minnect profiles host to turn an email + password into the // 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<string | null> {
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. * Runs the three-call sign-in: login -> auth/me -> feed-token exchange.
* Returns the person to show and the tokens to store server-side. * Returns the person to show and the tokens to store server-side.
@@ -278,8 +295,12 @@ export async function loginToMinnect(
options, 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 { return {
person: { userId, name, email: personEmail, pictureUrl }, person: { userId, name, email: personEmail, pictureUrl, feedUserId },
tokens: { tokens: {
profileAccessToken, profileAccessToken,
profileAccessTokenExpiresAt, profileAccessTokenExpiresAt,
+52
View File
@@ -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<DeleteResult> {
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;
}
}
+2
View File
@@ -12,6 +12,8 @@ export interface SessionPerson {
name: string; name: string;
email: string; email: string;
pictureUrl: string | null; 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 { export interface SessionData {
+127
View File
@@ -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<T>(fn: typeof fetch, run: () => Promise<T>) {
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<Response>)(url)) as unknown as typeof fetch;
expect((await loginToMinnect("a@b.co", "pw", { fetchImpl: down })).person.feedUserId).toBeNull();
});
});