((resolve) => {
+ resolveRecommendationResponse = resolve;
+ });
+ }
+
+ if (url.startsWith("https://api.themoviedb.org/")) {
+ return Promise.resolve(
+ createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"])
+ );
+ }
+
+ return Promise.reject(new Error(`Unhandled fetch: ${url}`));
+ }) as typeof fetch;
+
+ renderRecommendations();
+
+ await waitFor(() => {
+ expect(screen.getByText("Generating recommendations...")).toBeInTheDocument();
+ });
+
+ await act(async () => {
+ resolveRecommendationResponse?.(createRecommendationStreamResponse());
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "Mad Max: Fury Road (2015)" })
+ ).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Movie 1 of 2")).toBeInTheDocument();
+ expect(screen.getByAltText("Mad Max: Fury Road")).toBeInTheDocument();
+ });
+
+ it("renders a no-results state when the streamed response is empty", async () => {
+ global.fetch = jest.fn((input: RequestInfo | URL) => {
+ const url = getRequestUrl(input);
+
+ if (url === "/api/recommendations") {
+ return Promise.resolve(createRecommendationStreamResponse(recommendationFixtures.empty));
+ }
+
+ return Promise.reject(new Error(`Unhandled fetch: ${url}`));
+ }) as typeof fetch;
+
+ renderRecommendations();
+
+ await waitFor(() => {
+ expect(screen.getByText("No strong matches this time")).toBeInTheDocument();
+ });
+ });
+
+ it("renders the streaming error state when the recommendations request fails", async () => {
+ global.fetch = jest.fn((input: RequestInfo | URL) => {
+ const url = getRequestUrl(input);
+
+ if (url === "/api/recommendations") {
+ return Promise.resolve(
+ createTextStreamResponse("Pipeline failed", {
+ status: 500,
+ headers: {
+ "Content-Type": "text/plain; charset=utf-8",
+ },
+ })
+ );
+ }
+
+ return Promise.reject(new Error(`Unhandled fetch: ${url}`));
+ }) as typeof fetch;
+
+ renderRecommendations();
+
+ await waitFor(() => {
+ expect(screen.getByText("Oops! Something went wrong")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText(/Pipeline failed/)).toBeInTheDocument();
+ });
+
+ it("clears the movie session and routes home when Start Over is pressed", async () => {
+ global.fetch = jest.fn((input: RequestInfo | URL) => {
+ const url = getRequestUrl(input);
+
+ if (url === "/api/recommendations") {
+ return Promise.resolve(createRecommendationStreamResponse());
+ }
+
+ if (url.startsWith("https://api.themoviedb.org/")) {
+ return Promise.resolve(
+ createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"])
+ );
+ }
+
+ return Promise.reject(new Error(`Unhandled fetch: ${url}`));
+ }) as typeof fetch;
+
+ renderRecommendations();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "Mad Max: Fury Road (2015)" })
+ ).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Start Over" }));
+
+ expect(mockReplace).toHaveBeenCalledWith("/");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("session-state")).toHaveTextContent('"participantsCount":0');
+ });
+
+ expect(screen.getByTestId("session-state")).toHaveTextContent('"timeAvailable":""');
+ });
+});
diff --git a/app/(routes)/recommendations/RecommendationsClient.tsx b/app/(routes)/recommendations/RecommendationsClient.tsx
index a0f0aaa..3de6eaa 100644
--- a/app/(routes)/recommendations/RecommendationsClient.tsx
+++ b/app/(routes)/recommendations/RecommendationsClient.tsx
@@ -189,12 +189,12 @@ export default function RecommendationsClient() {
{hasRenderableCurrentMovie && (
<>
-
+
{currentMovieName} {currentMovieReleaseYear ? `(${currentMovieReleaseYear})` : ""}
{isLoading && currentIndex === recommendedMovies.length - 1 && (
)}
-
+
{isLoadingPoster ? (
({
+ streamMovieRecommendations: jest.fn(),
+}));
+
+import { streamMovieRecommendations } from "@/lib/services/movie-recommendations";
+
+describe("POST /api/recommendations integration", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("returns the streamed text response body on success", async () => {
+ const payload = {
+ participantsData: [
+ {
+ favouriteMovie: "Inception",
+ movieType: "new",
+ moodType: "fun",
+ favouriteFilmPerson: "Keanu Reeves",
+ },
+ ],
+ timeAvailable: "2 hours",
+ };
+
+ (streamMovieRecommendations as jest.Mock).mockResolvedValue({
+ toTextStreamResponse: () =>
+ createRecommendationStreamResponse(recommendationFixtures.groupJourney),
+ });
+
+ const response = await POST(
+ new Request("http://localhost:3000/api/recommendations", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ })
+ );
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe(JSON.stringify(recommendationFixtures.groupJourney));
+ expect(streamMovieRecommendations).toHaveBeenCalledWith(payload);
+ });
+
+ it("returns an empty recommendation payload when the pipeline finds no matches", async () => {
+ (streamMovieRecommendations as jest.Mock).mockResolvedValue(null);
+
+ const response = await POST(
+ new Request("http://localhost:3000/api/recommendations", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ participantsData: [],
+ timeAvailable: "90 minutes",
+ }),
+ })
+ );
+
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({ recommendedMovies: [] });
+ });
+
+ it("returns a 500 JSON payload when the pipeline throws", async () => {
+ (streamMovieRecommendations as jest.Mock).mockRejectedValue(new Error("Pipeline failed"));
+
+ const response = await POST(
+ new Request("http://localhost:3000/api/recommendations", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ participantsData: [],
+ timeAvailable: "90 minutes",
+ }),
+ })
+ );
+
+ expect(response.status).toBe(500);
+ await expect(response.json()).resolves.toEqual({ error: "Pipeline failed" });
+ });
+});
diff --git a/components/features/ParticipantsSetup.tsx b/components/features/ParticipantsSetup.tsx
index a45edf9..3228a61 100644
--- a/components/features/ParticipantsSetup.tsx
+++ b/components/features/ParticipantsSetup.tsx
@@ -50,7 +50,14 @@ const ParticipantsSetup = () => {
+
setGroupTimeAvailable(e.target.value)}
diff --git a/jest.config.js b/jest.config.js
index af0cc13..4876f25 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -9,6 +9,7 @@ const createJestConfig = nextJest({
const customJestConfig = {
setupFilesAfterEnv: ["/jest.setup.js"],
testEnvironment: "jsdom",
+ testPathIgnorePatterns: ["/node_modules/", "/.next/", "/tests/e2e/"],
moduleNameMapper: {
"^@/services/(.*)$": "/lib/services/$1",
"^@/config/(.*)$": "/lib/config/$1",
@@ -30,6 +31,9 @@ const customJestConfig = {
"!tailwind.config.ts",
"!app/fonts.ts",
"!app/layout.tsx",
+ "!playwright.config.ts",
+ "!tests/e2e/**",
+ "!tests/support/**",
],
coverageThreshold: {
global: {
diff --git a/jest.setup.js b/jest.setup.js
index b701334..b1eee13 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -1,5 +1,18 @@
// Learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";
-import { TransformStream } from "node:stream/web";
+import { TextDecoder, TextEncoder } from "node:util";
+import {
+ ReadableStream,
+ TextDecoderStream,
+ TransformStream,
+ WritableStream,
+} from "node:stream/web";
-Object.assign(global, { TransformStream });
+Object.assign(global, {
+ ReadableStream,
+ TextDecoder,
+ TextDecoderStream,
+ TextEncoder,
+ TransformStream,
+ WritableStream,
+});
diff --git a/next.config.ts b/next.config.ts
index 83e27bb..e3d929b 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -3,6 +3,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
+ allowedDevOrigins: ["127.0.0.1"],
images: {
remotePatterns: [
{
diff --git a/package.json b/package.json
index 7e62847..178382f 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,8 @@
"type-check": "tsc --noEmit",
"test": "jest",
"test:ci": "jest --ci",
+ "test:e2e": "playwright test",
+ "test:integration": "jest --runInBand --testPathPattern='\\.integration\\.test\\.(ts|tsx)$'",
"test:coverage": "jest --coverage",
"validate:push": "pnpm type-check && pnpm lint && pnpm test:coverage",
"prepare": "husky"
@@ -38,6 +40,7 @@
"@cloudflare/workers-types": "^4.20241224.0",
"@eslint/eslintrc": "^3",
"@next/bundle-analyzer": "16.2.1",
+ "@playwright/test": "^1.59.0",
"@testing-library/jest-dom": "6.1.5",
"@testing-library/react": "^16",
"@types/jest": "29.5.11",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..b0352eb
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,33 @@
+import { defineConfig, devices } from "@playwright/test";
+
+const baseURL = "http://127.0.0.1:3000";
+
+export default defineConfig({
+ testDir: "./tests/e2e",
+ fullyParallel: true,
+ forbidOnly: Boolean(process.env.CI),
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ outputDir: "output/playwright/test-results",
+ reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/report" }]],
+ use: {
+ baseURL,
+ trace: "on-first-retry",
+ screenshot: "only-on-failure",
+ video: "retain-on-failure",
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: {
+ ...devices["Desktop Chrome"],
+ },
+ },
+ ],
+ webServer: {
+ command: process.env.CI ? "pnpm start" : "pnpm build && pnpm start",
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ timeout: 180_000,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b2a87e4..10fb0d4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -31,7 +31,7 @@ importers:
version: 1.7.0(react@19.2.4)
next:
specifier: 16.2.1
- version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.59.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: ^19.2.4
version: 19.2.4
@@ -51,6 +51,9 @@ importers:
'@next/bundle-analyzer':
specifier: 16.2.1
version: 16.2.1
+ '@playwright/test':
+ specifier: ^1.59.0
+ version: 1.59.0
'@testing-library/jest-dom':
specifier: 6.1.5
version: 6.1.5(@jest/globals@29.7.0)(@types/jest@29.5.11)(jest@29.7.0(@types/node@22.19.15))
@@ -1012,6 +1015,11 @@ packages:
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
+ '@playwright/test@1.59.0':
+ resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -2197,6 +2205,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3109,6 +3122,16 @@ packages:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
+ playwright-core@1.59.0:
+ resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.59.0:
+ resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -4756,6 +4779,10 @@ snapshots:
'@opentelemetry/api@1.9.0': {}
+ '@playwright/test@1.59.0':
+ dependencies:
+ playwright: 1.59.0
+
'@polka/url@1.0.0-next.29': {}
'@poppinss/colors@4.1.6':
@@ -6192,6 +6219,9 @@ snapshots:
fs.realpath@1.0.0: {}
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -7149,7 +7179,7 @@ snapshots:
natural-compare@1.4.0: {}
- next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.59.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.2.1
'@swc/helpers': 0.5.15
@@ -7169,6 +7199,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.2.1
'@next/swc-win32-x64-msvc': 16.2.1
'@opentelemetry/api': 1.9.0
+ '@playwright/test': 1.59.0
babel-plugin-react-compiler: 1.0.0
sharp: 0.34.5
transitivePeerDependencies:
@@ -7336,6 +7367,14 @@ snapshots:
dependencies:
find-up: 4.1.0
+ playwright-core@1.59.0: {}
+
+ playwright@1.59.0:
+ dependencies:
+ playwright-core: 1.59.0
+ optionalDependencies:
+ fsevents: 2.3.2
+
possible-typed-array-names@1.1.0: {}
postcss-import@15.1.0(postcss@8.5.8):
diff --git a/tests/e2e/guard-and-validation.spec.ts b/tests/e2e/guard-and-validation.spec.ts
new file mode 100644
index 0000000..608e008
--- /dev/null
+++ b/tests/e2e/guard-and-validation.spec.ts
@@ -0,0 +1,25 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("Recommendation guardrails", () => {
+ test("shows validation feedback when the movie form is submitted empty", async ({ page }) => {
+ await page.goto("/");
+ await page.getByRole("button", { name: "Start" }).click();
+
+ await expect(page).toHaveURL(/\/movieForm$/);
+
+ await page.getByRole("button", { name: "Get Movie" }).click();
+
+ await expect(page.getByText("Please fill out all required fields")).toBeVisible();
+ await expect(page.getByText("This field is required")).toHaveCount(2);
+ await expect(page).toHaveURL(/\/movieForm$/);
+ });
+
+ test("redirects back home when recommendations are opened without session state", async ({
+ page,
+ }) => {
+ await page.goto("/recommendations");
+
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByRole("button", { name: "Start" })).toBeVisible();
+ });
+});
diff --git a/tests/e2e/happy-path.spec.ts b/tests/e2e/happy-path.spec.ts
new file mode 100644
index 0000000..be1a04a
--- /dev/null
+++ b/tests/e2e/happy-path.spec.ts
@@ -0,0 +1,62 @@
+import { expect, test } from "@playwright/test";
+import { stubMovieApis } from "./support/network";
+
+test.describe("Core recommendation journey", () => {
+ test("takes a two-person group from setup to recommendations and back home", async ({ page }) => {
+ await stubMovieApis(page);
+
+ await page.goto("/");
+
+ const participantsSlider = page.getByRole("slider", { name: "How many people?" });
+ await participantsSlider.focus();
+ await participantsSlider.press("ArrowRight");
+
+ await page.getByLabel("How much time do you have?").fill("2 hours");
+ await page.getByRole("button", { name: "Start" }).click();
+
+ await expect(page).toHaveURL(/\/movieForm$/);
+ await expect(page.getByRole("heading", { name: "Person #1" })).toBeVisible();
+
+ await page
+ .getByLabel(/what's your favourite movie and why/i)
+ .fill("I love practical action movies with huge set pieces and momentum.");
+ await page.getByRole("tab", { name: "Classic" }).click();
+ await page.getByRole("tab", { name: "Serious" }).click();
+ await page
+ .getByLabel(/which famous film person would you love to be stranded on an island with/i)
+ .fill("George Miller for the stories.");
+ await page.getByRole("button", { name: /^Next$/ }).click();
+
+ await expect(page.getByRole("heading", { name: "Person #2" })).toBeVisible();
+ await expect(page.getByLabel(/what's your favourite movie and why/i)).toHaveValue("");
+
+ await page
+ .getByLabel(/what's your favourite movie and why/i)
+ .fill("I want something warm, funny, and comforting for a relaxed night.");
+ await page
+ .getByLabel(/which famous film person would you love to be stranded on an island with/i)
+ .fill("Paddington, obviously.");
+
+ const recommendationsRequest = page.waitForRequest("**/api/recommendations");
+ const recommendationsResponse = page.waitForResponse("**/api/recommendations");
+ await page.getByRole("button", { name: "Get Movie" }).click();
+
+ await expect(page).toHaveURL(/\/recommendations$/);
+ await recommendationsRequest;
+ await recommendationsResponse;
+ await expect(page.getByRole("heading", { name: "Mad Max: Fury Road (2015)" })).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect(page.getByRole("img", { name: "Mad Max: Fury Road" })).toBeVisible();
+
+ await page.getByRole("button", { name: "Next Movie" }).click();
+ await expect(page.getByRole("heading", { name: "Paddington 2 (2017)" })).toBeVisible();
+ await expect(page.getByRole("img", { name: "Paddington 2" })).toBeVisible();
+
+ await page.getByRole("button", { name: "Start Over" }).click();
+
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByRole("button", { name: "Start" })).toBeVisible();
+ await expect(page.getByLabel("How much time do you have?")).toHaveValue("");
+ });
+});
diff --git a/tests/e2e/support/network.ts b/tests/e2e/support/network.ts
new file mode 100644
index 0000000..6f1d2dd
--- /dev/null
+++ b/tests/e2e/support/network.ts
@@ -0,0 +1,73 @@
+import type { Page } from "@playwright/test";
+import {
+ createRecommendationTextBody,
+ recommendationFixtures,
+ tmdbFixtures,
+ type RecommendationFixture,
+ type TmdbSearchResponse,
+} from "../../support/movie-test-fixtures";
+
+type StubMovieApisOptions = {
+ recommendations?: RecommendationFixture;
+ recommendationsStatus?: number;
+ tmdbResponsesByQuery?: Record;
+};
+
+const ONE_PIXEL_PNG = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sN0xG4AAAAASUVORK5CYII=",
+ "base64"
+);
+
+export async function stubMovieApis(
+ page: Page,
+ {
+ recommendations = recommendationFixtures.groupJourney,
+ recommendationsStatus = 200,
+ tmdbResponsesByQuery = tmdbFixtures.postersByQuery,
+ }: StubMovieApisOptions = {}
+) {
+ await page.route("**/api/recommendations", async (route) => {
+ if (recommendationsStatus >= 400) {
+ await route.fulfill({
+ status: recommendationsStatus,
+ contentType: "text/plain; charset=utf-8",
+ body: "Pipeline failed",
+ });
+ return;
+ }
+
+ await route.fulfill({
+ status: 200,
+ contentType: "text/plain; charset=utf-8",
+ body: createRecommendationTextBody(recommendations),
+ });
+ });
+
+ await page.route("https://api.themoviedb.org/**", async (route) => {
+ const requestUrl = new URL(route.request().url());
+ const query = requestUrl.searchParams.get("query") ?? "";
+ const body = tmdbResponsesByQuery[query] ?? tmdbFixtures.empty;
+
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(body),
+ });
+ });
+
+ await page.route("**/_next/image**", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "image/png",
+ body: ONE_PIXEL_PNG,
+ });
+ });
+
+ await page.route("https://image.tmdb.org/**", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "image/png",
+ body: ONE_PIXEL_PNG,
+ });
+ });
+}
diff --git a/tests/support/movie-test-fixtures.ts b/tests/support/movie-test-fixtures.ts
new file mode 100644
index 0000000..908bf08
--- /dev/null
+++ b/tests/support/movie-test-fixtures.ts
@@ -0,0 +1,102 @@
+export const recommendationFixtures = {
+ groupJourney: {
+ recommendedMovies: [
+ {
+ name: "Mad Max: Fury Road",
+ releaseYear: "2015",
+ synopsis: "A relentless desert chase with furious stunts and a huge heart.",
+ },
+ {
+ name: "Paddington 2",
+ releaseYear: "2017",
+ synopsis: "Paddington brings warmth, chaos, and kindness to a city mystery.",
+ },
+ ],
+ },
+ empty: {
+ recommendedMovies: [],
+ },
+} as const;
+
+export type RecommendationFixture =
+ (typeof recommendationFixtures)[keyof typeof recommendationFixtures];
+
+export function createTmdbSearchResponse(posterPath?: string) {
+ return {
+ results: posterPath ? [{ poster_path: posterPath }] : [],
+ };
+}
+
+export const tmdbFixtures = {
+ postersByQuery: {
+ "Mad Max: Fury Road": createTmdbSearchResponse("/mad-max-fury-road.jpg"),
+ "Paddington 2": createTmdbSearchResponse("/paddington-2.jpg"),
+ },
+ empty: createTmdbSearchResponse(),
+} as const;
+
+export type TmdbSearchResponse = ReturnType;
+
+function createTextStream(text: string, chunkSize = text.length): ReadableStream {
+ const encoder = new TextEncoder();
+
+ return new ReadableStream({
+ start(controller) {
+ for (let index = 0; index < text.length; index += chunkSize) {
+ controller.enqueue(encoder.encode(text.slice(index, index + chunkSize)));
+ }
+
+ controller.close();
+ },
+ });
+}
+
+export function createRecommendationTextBody(
+ payload: RecommendationFixture = recommendationFixtures.groupJourney
+): string {
+ return JSON.stringify(payload);
+}
+
+export function createTextStreamResponse(text: string, init: ResponseInit = {}): Response {
+ const headers = new Headers(init.headers);
+ const status = init.status ?? 200;
+
+ if (!headers.has("Content-Type")) {
+ headers.set("Content-Type", "text/plain; charset=utf-8");
+ }
+
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ headers,
+ body: createTextStream(text),
+ text: async () => text,
+ json: async () => JSON.parse(text),
+ } as Response;
+}
+
+export function createJsonResponse(payload: unknown, init: ResponseInit = {}): Response {
+ const text = JSON.stringify(payload);
+ const headers = new Headers(init.headers);
+ const status = init.status ?? 200;
+
+ if (!headers.has("Content-Type")) {
+ headers.set("Content-Type", "application/json");
+ }
+
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ headers,
+ body: createTextStream(text),
+ text: async () => text,
+ json: async () => payload,
+ } as Response;
+}
+
+export function createRecommendationStreamResponse(
+ payload: RecommendationFixture = recommendationFixtures.groupJourney,
+ init: ResponseInit = {}
+): Response {
+ return createTextStreamResponse(createRecommendationTextBody(payload), init);
+}