q12xo172i: Render post cards in the signed-in home feed

This commit is contained in:
2026-10-19 14:01:17 -06:00
parent 20af24b25d
commit 18e0386a99
9 changed files with 581 additions and 1 deletions
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { FeedView, type FeedViewProps } from "@/components/FeedView";
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
import { mergePosts, relativeTime, toCardPosts } from "@/lib/feed-model";
const NOW = Date.parse("2026-10-19T12:00:00Z");
const post = (id: string, publishedAt: string, extra: Record<string, unknown> = {}) => ({
type: "post",
publishedAt,
likesCount: 3,
commentsCount: 2,
post: {
id,
content: `text ${id}\nsecond line`,
author: { id: "a", displayName: `Author ${id}`, profilePictureUrl: "https://img/a.jpg" },
mediaItems: [],
...extra,
},
});
function render(over: Partial<FeedViewProps>) {
const props: FeedViewProps = {
posts: [], status: "ready", loadingMore: false, loadMoreFailed: false,
newCount: 0, onRetry: () => {}, onShowNew: () => {}, now: NOW, ...over,
};
return renderToStaticMarkup(createElement(FeedView, props));
}
describe("feed model", () => {
it("skips non-post items and posts without a post object", () => {
const items = [
{ type: "trending-conversations", conversations: [] },
{ type: "post", publishedAt: "2026-10-19T11:00:00Z" },
post("1", "2026-10-19T11:00:00Z"),
];
expect(toCardPosts(items).map((p) => p.id)).toEqual(["1"]);
});
it("sorts strictly by publishedAt descending, interleaving own posts", () => {
// API returns own posts first; they must land in time order.
const items = [
post("own-old", "2026-10-19T08:00:00Z"),
post("own-new", "2026-10-19T11:30:00Z"),
post("other-mid", "2026-10-19T10:00:00Z"),
post("other-new", "2026-10-19T11:45:00Z"),
];
expect(mergePosts(toCardPosts(items)).map((p) => p.id)).toEqual([
"other-new", "own-new", "other-mid", "own-old",
]);
});
it("dedupes and re-sorts across pages", () => {
const a = toCardPosts([post("1", "2026-10-19T10:00:00Z")]);
const b = toCardPosts([post("1", "2026-10-19T10:00:00Z"), post("2", "2026-10-19T11:00:00Z")]);
expect(mergePosts(a, b).map((p) => p.id)).toEqual(["2", "1"]);
});
it("falls back to first+last name and picks the image url in order", () => {
const [p] = toCardPosts([
post("1", "2026-10-19T10:00:00Z", {
author: { firstName: "Ada", lastName: "Lovelace" },
mediaItems: [
{ order: 2, mediaType: "image", imageUrl: "https://i/2.jpg" },
{ order: 1, mediaType: "image", imageMediumUrl: "https://i/1m.jpg", imageUrl: "https://i/1.jpg" },
{ order: 3, mediaType: "image", imageMediumWebpUrl: "https://i/3.webp", imageUrl: "https://i/3.jpg" },
{ order: 4, mediaType: "video", thumbnailUrl: "https://i/v.jpg", hlsUrl: "https://stream.mux.com/x" },
],
}),
]);
expect(p.authorName).toBe("Ada Lovelace");
expect(p.media.map((m) => m.src)).toEqual([
"https://i/1m.jpg", "https://i/2.jpg", "https://i/3.webp", "https://i/v.jpg",
]);
});
it("formats relative time", () => {
expect(relativeTime("2026-10-19T11:55:00Z", NOW)).toBe("5m ago");
expect(relativeTime("2026-10-19T09:00:00Z", NOW)).toBe("3h ago");
});
});
describe("FeedView", () => {
it("renders cards with full multi-line text, counts and avatar", () => {
const long = "x".repeat(2000);
const posts = toCardPosts([
post("1", "2026-10-19T11:00:00Z", { content: `line one\nline two ${long}` }),
post("2", "2026-10-19T10:00:00Z", { author: { displayName: "No Pic" } }),
]);
const html = render({ posts });
expect((html.match(/data-testid="post-card"/g) ?? []).length).toBe(2);
expect(html).toContain("line one\nline two " + long);
expect(html).toContain('src="https://img/a.jpg"');
expect(html).toContain("NP"); // initials tile
expect(html).toContain("1h ago");
expect(html).toContain("3 likes");
expect(html).toContain("2 comments");
});
it("does not render a non-post item", () => {
const posts = toCardPosts([{ type: "trending-conversations" }, post("1", "2026-10-19T11:00:00Z")]);
const html = render({ posts });
expect((html.match(/data-testid="post-card"/g) ?? []).length).toBe(1);
expect(html).not.toContain("trending");
});
it("empty state", () => {
expect(render({ posts: [] })).toContain("No posts yet.");
});
it("error state with retry", () => {
const html = render({ status: "error" });
expect(html).toContain("The feed did not load.");
expect(html).toContain("Try again");
});
it("spinner while loading more and a new-posts control", () => {
const html = render({ posts: toCardPosts([post("1", "2026-10-19T11:00:00Z")]), loadingMore: true, newCount: 2 });
expect(html).toContain('class="spinner"');
expect(html).toContain("2 new posts");
expect(html).not.toContain("Load more");
});
});
describe("fetchFeedPage", () => {
it("returns items + nextCursor and sends the cursor", async () => {
const f = vi.fn(async () => new Response(JSON.stringify({ items: [1], nextCursor: "C" }), { status: 200 }));
const page = await fetchFeedPage("X", f as unknown as typeof fetch);
expect(page).toEqual({ items: [1], nextCursor: "C" });
expect(String((f.mock.calls[0] as unknown[])[0])).toContain("cursor=X");
});
it("401 -> signedOut, 500 -> not", async () => {
const mk = (s: number) => (async () => new Response("{}", { status: s })) as unknown as typeof fetch;
await expect(fetchFeedPage(null, mk(401))).rejects.toMatchObject({ signedOut: true });
await expect(fetchFeedPage(null, mk(500))).rejects.toBeInstanceOf(FeedLoadError);
await expect(fetchFeedPage(null, mk(500))).rejects.toMatchObject({ signedOut: false });
});
});