248 lines
11 KiB
TypeScript
248 lines
11 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { createElement } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { FeedView, RefreshButton, type FeedViewProps } from "@/components/FeedView";
|
|
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
|
import { commentsLabel, formatDuration, mergePosts, relativeTime, toCardPost, toCardPosts, upgradeVideos } 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("skeleton card while loading more and the new-posts pill", () => {
|
|
const html = render({ posts: toCardPosts([post("1", "2026-10-19T11:00:00Z")]), loadingMore: true, newCount: 2 });
|
|
expect(html).toContain('data-testid="feed-skeleton"');
|
|
expect(html).not.toContain('class="spinner"');
|
|
expect(html).toContain("btn-grow");
|
|
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 });
|
|
});
|
|
});
|
|
|
|
describe("video transcoding", () => {
|
|
const vid = (hls: unknown) =>
|
|
toCardPosts([post("7", "2026-10-19T10:00:00Z", { mediaItems: [{ mediaType: "video", thumbnailUrl: "https://i/t.jpg", hlsUrl: hls }] })]);
|
|
|
|
it("keeps a just-published video as a thumbnail with no hlsUrl", () => {
|
|
const [p] = vid(null);
|
|
expect(p.media).toEqual([{ kind: "video", src: "https://i/t.jpg", hlsUrl: null, mp4Url: null }]);
|
|
});
|
|
|
|
it("keeps a video with neither thumbnail nor hlsUrl as a pending card", () => {
|
|
const [p] = toCardPosts([post("8", "2026-10-19T10:00:00Z", { mediaItems: [{ mediaType: "video" }] })]);
|
|
expect(p.media).toHaveLength(1);
|
|
expect(p.media[0].src).toBeNull();
|
|
});
|
|
|
|
it("upgrades the shown thumbnail to the player when hlsUrl appears", () => {
|
|
const shown = vid(null);
|
|
const next = upgradeVideos(shown, vid("https://stream.mux.com/abc"));
|
|
expect(next[0].media[0].hlsUrl).toContain("stream.mux.com");
|
|
expect(upgradeVideos(next, vid("https://stream.mux.com/abc"))).toBe(next);
|
|
expect(upgradeVideos(shown, vid(null))).toBe(shown);
|
|
});
|
|
|
|
it("captures mp4Url on the feed CDN and rejects other hosts", () => {
|
|
const mk = (u: unknown) =>
|
|
toCardPosts([post("9", "2026-10-19T10:00:00Z", { mediaItems: [{ mediaType: "video", thumbnailUrl: "https://i/t.jpg", mp4Url: u }] })])[0].media[0];
|
|
expect(mk("https://cdn.feed.sharedservices.vt.com/v.mp4").mp4Url).toBe("https://cdn.feed.sharedservices.vt.com/v.mp4");
|
|
expect(mk("https://evil.example/v.mp4").mp4Url).toBeNull();
|
|
expect(mk("http://cdn.feed.sharedservices.vt.com/v.mp4").mp4Url).toBeNull();
|
|
});
|
|
|
|
it("upgrades the thumbnail in place when only mp4Url appears", () => {
|
|
const withMp4 = toCardPosts([post("7", "2026-10-19T10:00:00Z", { mediaItems: [{ mediaType: "video", thumbnailUrl: "https://i/t.jpg", mp4Url: "https://cdn.feed.sharedservices.vt.com/v.mp4" }] })]);
|
|
const shown = vid(null);
|
|
const next = upgradeVideos(shown, withMp4);
|
|
expect(next[0].media[0].mp4Url).toContain("cdn.feed");
|
|
expect(upgradeVideos(next, vid(null))).toBe(next);
|
|
});
|
|
|
|
it("renders the thumbnail while transcoding and the player once hlsUrl is present", () => {
|
|
const html = render({ posts: vid(null) });
|
|
expect(html).toContain("https://i/t.jpg");
|
|
expect(html).not.toContain("<video");
|
|
const live = render({ posts: vid("https://stream.mux.com/abc") });
|
|
expect(live).toContain("<video");
|
|
});
|
|
});
|
|
|
|
describe("video duration", () => {
|
|
it("reads videoDuration (seconds) first and formats it as m:ss", () => {
|
|
const [m] = toCardPost({ type: "post", post: { id: "v", author: {}, mediaItems: [{ mediaType: "video", videoDuration: 154, duration: 9 }] } })!.media;
|
|
expect(m.durationSeconds).toBe(154);
|
|
expect(formatDuration(154)).toBe("2:34");
|
|
expect(formatDuration(5)).toBe("0:05");
|
|
expect(formatDuration(3725)).toBe("1:02:05");
|
|
expect(formatDuration(0)).toBeNull();
|
|
expect(formatDuration(undefined)).toBeNull();
|
|
});
|
|
it("falls back to durationSeconds, then duration, else none", () => {
|
|
const dur = (item: object) => toCardPost({ type: "post", post: { id: "v", author: {}, mediaItems: [{ mediaType: "video", ...item }] } })!.media[0].durationSeconds;
|
|
expect(dur({ durationSeconds: 30 })).toBe(30);
|
|
expect(dur({ duration: 12 })).toBe(12);
|
|
expect(dur({})).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("refresh button", () => {
|
|
it("is in the Home feed header with its accessible name", () => {
|
|
const html = render({ onRefresh: () => {} });
|
|
expect(html).toContain('aria-label="Refresh feed"');
|
|
expect(html.indexOf("Home feed")).toBeLessThan(html.indexOf("Refresh feed"));
|
|
});
|
|
|
|
it("is absent without a handler", () => {
|
|
expect(render({})).not.toContain("Refresh feed");
|
|
});
|
|
|
|
it("triggers a reload when clicked", () => {
|
|
const onRefresh = vi.fn();
|
|
const el = RefreshButton({ onRefresh }) as { props: { onClick: () => void } };
|
|
el.props.onClick();
|
|
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("is disabled and spinning while a reload runs", () => {
|
|
const html = render({ onRefresh: () => {}, refreshing: true });
|
|
expect(html).toMatch(/<button[^>]*disabled[^>]*aria-label="Refresh feed"|<button[^>]*aria-label="Refresh feed"[^>]*disabled/);
|
|
expect(html).toContain("refresh-icon-spin");
|
|
});
|
|
});
|
|
|
|
describe("comments label", () => {
|
|
it("is singular for 1 and plural otherwise", () => {
|
|
expect(commentsLabel(1)).toBe("1 comment");
|
|
expect(commentsLabel(0)).toBe("0 comments");
|
|
expect(commentsLabel(2)).toBe("2 comments");
|
|
});
|
|
|
|
it("renders singular on a one-comment post", () => {
|
|
const posts = toCardPosts([{ ...post("1", "2026-10-19T11:00:00Z"), commentsCount: 1 }]);
|
|
const html = render({ posts });
|
|
expect(html).toContain("1 comment<");
|
|
expect(html).not.toContain("1 comments");
|
|
});
|
|
});
|