From 4ee6493eb8eb9cbe88d189882638683d9e7a11b4 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 1 Mar 2025 17:25:57 +0100 Subject: [PATCH] Fix 404 handling for entries --- api/src/controllers/entries.ts | 111 ++++--- api/tests/entries/get-entries.test.ts | 30 ++ api/tests/entries/seed-entries.ts | 413 -------------------------- api/tests/helpers/series-helper.ts | 29 ++ 4 files changed, 131 insertions(+), 452 deletions(-) create mode 100644 api/tests/entries/get-entries.test.ts delete mode 100644 api/tests/entries/seed-entries.ts diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index dbdd42bd..9bfb1124 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -74,37 +74,21 @@ const extraSort = Sort(["slug", "name", "runtime", "createdAt"], { default: ["slug"], }); -async function getEntries( - serie: string | null, - { - after, - limit, - query, - sort, - filter, - languages, - }: { - after: string | undefined; - limit: number; - query: string | undefined; - sort: StaticDecode; - filter: SQL | undefined; - languages: string[]; - }, -): Promise<(Entry | Extra | UnknownEntry)[]> { - const show = db.$with("serie").as( - db - .select({ pk: shows.pk }) - .from(shows) - .where( - and( - eq(shows.kind, "serie"), - isUuid(serie!) ? eq(shows.id, serie!) : eq(shows.slug, serie!), - ), - ) - .limit(1), - ); - +async function getEntries({ + after, + limit, + query, + sort, + filter, + languages, +}: { + after: string | undefined; + limit: number; + query: string | undefined; + sort: StaticDecode; + filter: SQL | undefined; + languages: string[]; +}): Promise<(Entry | Extra | UnknownEntry)[]> { const transQ = db .selectDistinctOn([entryTranslations.pk]) .from(entryTranslations) @@ -125,7 +109,6 @@ async function getEntries( ...entryCol } = getColumns(entries); return await db - .with(...(serie ? [show] : [])) .select({ ...entryCol, ...transCol, @@ -133,10 +116,10 @@ async function getEntries( number: sql`${episodeNumber}`.as("order"), // merge `extraKind` into `kind` - kind: sql`case when ${kind} = 'extra' then ${extraKind} else ${kind} end`.as( + kind: sql`case when ${kind} = 'extra' then ${extraKind} else ${kind}::text end`.as( "kind", ), - isExtra: sql`${kind} = "extra"`.as("isExtra"), + isExtra: sql`${kind} = 'extra'`.as("isExtra"), // assign more restrained types to make typescript happy. externalId: sql`${externalId}`.as("externalId"), @@ -149,7 +132,6 @@ async function getEntries( .innerJoin(transQ, eq(entries.pk, transQ.pk)) .where( and( - serie ? eq(entries.showPk, show.pk) : undefined, filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, keysetPaginate({ table: entries, after, sort }), @@ -184,14 +166,34 @@ export const entriesH = new Elysia({ tags: ["series"] }) query: { limit, after, query, sort, filter }, headers: { "accept-language": languages }, request: { url }, + error, }) => { + const [serie] = await db + .select({ pk: shows.pk }) + .from(shows) + .where( + and( + eq(shows.kind, "serie"), + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + ), + ) + .limit(1); + + if (!serie) { + return error(404, { + status: 404, + message: `No serie with the id or slug: '${id}'.`, + }); + } + const langs = processLanguages(languages); - const items = (await getEntries(id, { + const items = (await getEntries({ limit, after, query, sort, filter: and( + eq(entries.showPk, serie.pk), ne(entries.kind, "extra"), ne(entries.kind, "unknown"), filter, @@ -226,6 +228,10 @@ export const entriesH = new Elysia({ tags: ["series"] }) }), response: { 200: Page(Entry), + 404: { + ...KError, + description: "No serie found with the given id or slug.", + }, 422: KError, }, }, @@ -236,13 +242,36 @@ export const entriesH = new Elysia({ tags: ["series"] }) params: { id }, query: { limit, after, query, sort, filter }, request: { url }, + error, }) => { - const items = (await getEntries(id, { + const [serie] = await db + .select({ pk: shows.pk }) + .from(shows) + .where( + and( + eq(shows.kind, "serie"), + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + ), + ) + .limit(1); + + if (!serie) { + return error(404, { + status: 404, + message: `No serie with the id or slug: '${id}'.`, + }); + } + + const items = (await getEntries({ limit, after, query, sort: sort as any, - filter: and(eq(entries.kind, "extra"), filter), + filter: and( + eq(entries.showPk, serie.pk), + eq(entries.kind, "extra"), + filter, + ), languages: ["extra"], })) as Extra[]; @@ -270,6 +299,10 @@ export const entriesH = new Elysia({ tags: ["series"] }) }), response: { 200: Page(Extra), + 404: { + ...KError, + description: "No serie found with the given id or slug.", + }, 422: KError, }, }, @@ -280,7 +313,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) query: { limit, after, query, sort, filter }, request: { url }, }) => { - const items = (await getEntries(null, { + const items = (await getEntries({ limit, after, query, diff --git a/api/tests/entries/get-entries.test.ts b/api/tests/entries/get-entries.test.ts new file mode 100644 index 00000000..ae70e1f8 --- /dev/null +++ b/api/tests/entries/get-entries.test.ts @@ -0,0 +1,30 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { getEntries } from "tests/helpers"; +import { expectStatus } from "tests/utils"; +import { seedSerie } from "~/controllers/seed/series"; +import { madeInAbyss } from "~/models/examples"; + +let miaId = ""; + +beforeAll(async () => { + const ret = await seedSerie(madeInAbyss); + if (!("status" in ret)) miaId = ret.id; +}); + +describe("Get entries", () => { + it("Invalid slug", async () => { + const [resp, body] = await getEntries("sotneuhn", { langs: "en" }); + + expectStatus(resp, body).toBe(404); + expect(body).toMatchObject({ + status: 404, + message: expect.any(String), + }); + }); + it("Default sort order", async () => { + const [resp, body] = await getEntries(madeInAbyss.slug, { langs: "en" }); + + expectStatus(resp, body).toBe(200); + expect(body.items).toBeArrayOfSize(madeInAbyss.entries.length); + }); +}); diff --git a/api/tests/entries/seed-entries.ts b/api/tests/entries/seed-entries.ts deleted file mode 100644 index 34e721de..00000000 --- a/api/tests/entries/seed-entries.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { beforeAll, describe, expect, it } from "bun:test"; -import { eq } from "drizzle-orm"; -import { expectStatus } from "tests/utils"; -import { db } from "~/db"; -import { showTranslations, shows, videos } from "~/db/schema"; -import { bubble } from "~/models/examples"; -import { dune, duneVideo } from "~/models/examples/dune-2021"; -import { createMovie, createVideo } from "../helpers"; - -describe("Movie seeding", () => { - it("Can create a movie", async () => { - // create video beforehand to test linking - await db.insert(videos).values(duneVideo); - - const [resp, body] = await createMovie(dune); - expectStatus(resp, body).toBe(201); - expect(body.id).toBeString(); - expect(body.slug).toBe("dune"); - expect(body.videos).toContainEqual({ slug: "dune" }); - }); - - it("Update existing movie", async () => { - // confirm that db is in the correct state (from previous tests) - const [existing] = await db - .select() - .from(shows) - .where(eq(shows.slug, dune.slug)) - .limit(1); - expect(existing).toMatchObject({ slug: dune.slug, startAir: dune.airDate }); - - const [resp, body] = await createMovie({ - ...dune, - runtime: 200_000, - translations: { - ...dune.translations, - en: { ...dune.translations.en, description: "edited translation" }, - fr: { - name: "dune-but-in-french", - description: null, - tagline: null, - aliases: [], - tags: [], - poster: null, - thumbnail: null, - banner: null, - logo: null, - trailerUrl: null, - }, - }, - }); - const [edited] = await db - .select() - .from(shows) - .where(eq(shows.slug, dune.slug)) - .limit(1); - const translations = await db - .select() - .from(showTranslations) - .where(eq(showTranslations.pk, edited.pk)); - - expectStatus(resp, body).toBe(200); - expect(body.id).toBeString(); - expect(body.slug).toBe("dune"); - expect(body.videos).toBeArrayOfSize(0); - expect(edited.runtime).toBe(200_000); - expect(edited.status).toBe(dune.status); - expect(translations.find((x) => x.language === "en")).toMatchObject({ - name: dune.translations.en.name, - description: "edited translation", - }); - expect(translations.find((x) => x.language === "fr")).toMatchObject({ - name: "dune-but-in-french", - description: null, - }); - }); - - it("Conflicting slug auto-correct", async () => { - // confirm that db is in the correct state (from previous tests) - const [existing] = await db - .select() - .from(shows) - .where(eq(shows.slug, dune.slug)) - .limit(1); - expect(existing).toMatchObject({ slug: dune.slug, startAir: dune.airDate }); - - const [resp, body] = await createMovie({ ...dune, airDate: "2158-12-13" }); - expectStatus(resp, body).toBe(201); - expect(body.id).toBeString(); - expect(body.slug).toBe("dune-2158"); - }); - - it("Conflict in slug w/out year fails", async () => { - // confirm that db is in the correct state (from conflict auto-correct test) - const [existing] = await db - .select() - .from(shows) - .where(eq(shows.slug, dune.slug)) - .limit(1); - expect(existing).toMatchObject({ slug: dune.slug, startAir: dune.airDate }); - - const [resp, body] = await createMovie({ ...dune, airDate: null }); - expectStatus(resp, body).toBe(409); - expect(body.id).toBe(existing.id); - expect(body.slug).toBe(existing.slug); - }); - - it("Missing videos send info", async () => { - const vid = "a0ddf0ce-3258-4452-a670-aff36c76d524"; - const [existing] = await db - .select() - .from(videos) - .where(eq(videos.id, vid)) - .limit(1); - expect(existing).toBeUndefined(); - - const [resp, body] = await createMovie({ - ...dune, - videos: [vid], - }); - - expectStatus(resp, body).toBe(200); - expect(body.videos).toBeArrayOfSize(0); - }); - - it("Schema error (missing fields)", async () => { - const [resp, body] = await createMovie({ - name: "dune", - } as any); - - expectStatus(resp, body).toBe(422); - expect(body.status).toBe(422); - expect(body.message).toBeString(); - expect(body.details).toBeObject(); - // TODO: handle additional fields too - }); - - it("Invalid translation name", async () => { - const [resp, body] = await createMovie({ - ...dune, - translations: { - ...dune.translations, - test: { - name: "foo", - description: "bar", - tags: [], - aliases: [], - tagline: "toto", - banner: null, - poster: null, - thumbnail: null, - logo: null, - trailerUrl: null, - }, - }, - }); - - expectStatus(resp, body).toBe(422); - expect(body.status).toBe(422); - expect(body.message).toBe("Invalid translation name: 'test'."); - }); - - it("Correct translations casing.", async () => { - const [resp, body] = await createMovie({ - ...bubble, - slug: "casing-test", - originalLanguage: "jp-jp", - translations: { - "en-us": { - name: "foo", - description: "bar", - tags: [], - aliases: [], - tagline: "toto", - banner: null, - poster: null, - thumbnail: null, - logo: null, - trailerUrl: null, - }, - }, - }); - - expect(resp.status).toBeWithin(200, 299); - expect(body.id).toBeString(); - const ret = await db.query.shows.findFirst({ - where: eq(shows.id, body.id), - with: { translations: true }, - }); - expect(ret!.originalLanguage).toBe("jp-JP"); - expect(ret!.translations).toBeArrayOfSize(2); - expect(ret!.translations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - language: "en-US", - name: "foo", - }), - ]), - ); - expect(ret!.translations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - language: "en", - name: "foo", - }), - ]), - ); - }); - - it("Refuses random as a slug", async () => { - const [resp, body] = await createMovie({ - ...bubble, - slug: "random", - airDate: null, - }); - expectStatus(resp, body).toBe(422); - }); - it("Refuses random as a slug but fallback w/ airDate", async () => { - const [resp, body] = await createMovie({ ...bubble, slug: "random" }); - expectStatus(resp, body).toBe(201); - expect(body.slug).toBe("random-2022"); - }); - - it("Handle fallback translations", async () => { - const [resp, body] = await createMovie({ - ...bubble, - slug: "bubble-translation-test", - translations: { "en-us": bubble.translations.en }, - }); - expectStatus(resp, body).toBe(201); - - const ret = await db.query.shows.findFirst({ - where: eq(shows.id, body.id), - with: { translations: true }, - }); - expect(ret!.translations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: bubble.translations.en.name, - language: "en", - }), - ]), - ); - expect(ret!.translations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: bubble.translations.en.name, - language: "en-US", - }), - ]), - ); - }); - it("No fallback if explicit", async () => { - const [resp, body] = await createMovie({ - ...bubble, - slug: "bubble-translation-test-2", - translations: { - "en-us": bubble.translations.en, - "en-au": { ...bubble.translations.en, name: "australian thing" }, - en: { ...bubble.translations.en, name: "Generic" }, - }, - }); - expectStatus(resp, body).toBe(201); - - const ret = await db.query.shows.findFirst({ - where: eq(shows.id, body.id), - with: { translations: true }, - }); - expect(ret!.translations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: bubble.translations.en.name, - language: "en-US", - }), - ]), - ); - expect(ret!.translations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "australian thing", - description: bubble.translations.en.description, - language: "en-AU", - }), - ]), - ); - expect(ret!.translations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "Generic", - description: bubble.translations.en.description, - language: "en", - }), - ]), - ); - }); - - it("Create correct video slug", async () => { - const [vresp, video] = await createVideo({ - path: "/video/bubble.mkv", - part: null, - version: 1, - rendering: "oeunhtoeuth", - }); - expectStatus(vresp, video).toBe(201); - - const [resp, body] = await createMovie({ - ...bubble, - slug: "video-slug-test1", - videos: [video[0].id], - }); - expectStatus(resp, body).toBe(201); - - const ret = await db.query.videos.findFirst({ - where: eq(videos.id, video[0].id), - with: { evj: { with: { entry: true } } }, - }); - expect(ret).not.toBe(undefined); - expect(ret!.evj).toBeArrayOfSize(1); - expect(ret!.evj[0].slug).toBe("video-slug-test1"); - }); - - it("Create correct video slug (version)", async () => { - const [vresp, video] = await createVideo({ - path: "/video/bubble2.mkv", - part: null, - version: 2, - rendering: "oeunhtoeuth", - }); - expectStatus(vresp, video).toBe(201); - - const [resp, body] = await createMovie({ - ...bubble, - slug: "bubble-vtest", - videos: [video[0].id], - }); - expectStatus(resp, body).toBe(201); - - const ret = await db.query.videos.findFirst({ - where: eq(videos.id, video[0].id), - with: { evj: { with: { entry: true } } }, - }); - expect(ret).not.toBe(undefined); - expect(ret!.evj).toBeArrayOfSize(1); - expect(ret!.evj[0].slug).toBe("bubble-vtest-v2"); - }); - it("Create correct video slug (part)", async () => { - const [vresp, video] = await createVideo({ - path: "/video/bubble5.mkv", - part: 1, - version: 2, - rendering: "oaoeueunhtoeuth", - }); - expectStatus(vresp, video).toBe(201); - - const [resp, body] = await createMovie({ - ...bubble, - slug: "bubble-ptest", - videos: [video[0].id], - }); - expectStatus(resp, body).toBe(201); - - const ret = await db.query.videos.findFirst({ - where: eq(videos.id, video[0].id), - with: { evj: { with: { entry: true } } }, - }); - expect(ret).not.toBe(undefined); - expect(ret!.evj).toBeArrayOfSize(1); - expect(ret!.evj[0].slug).toBe("bubble-ptest-p1-v2"); - }); - it("Create correct video slug (rendering)", async () => { - const [vresp, video] = await createVideo([ - { - path: "/video/bubble3.mkv", - part: null, - version: 1, - rendering: "oeunhtoeuth", - }, - { - path: "/video/bubble4.mkv", - part: null, - version: 1, - rendering: "aoeuaoeu", - }, - ]); - expectStatus(vresp, video).toBe(201); - - const [resp, body] = await createMovie({ - ...bubble, - slug: "bubble-rtest", - videos: [video[0].id, video[1].id], - }); - expectStatus(resp, body).toBe(201); - - const ret = await db.query.shows.findFirst({ - where: eq(shows.id, body.id), - with: { entries: { with: { evj: { with: { entry: true } } } } }, - }); - expect(ret).not.toBe(undefined); - expect(ret!.entries).toBeArrayOfSize(1); - expect(ret!.entries[0].slug).toBe("bubble-rtest"); - expect(ret!.entries[0].evj).toBeArrayOfSize(2); - expect(ret!.entries[0].evj).toContainValues([ - expect.objectContaining({ slug: "bubble-rtest" }), - expect.objectContaining({ slug: "bubble-rtest-aoeuaoeu" }), - ]); - }); -}); - -const cleanup = async () => { - await db.delete(shows); - await db.delete(videos); -}; -// cleanup db beforehand to unsure tests are consistent -beforeAll(cleanup); diff --git a/api/tests/helpers/series-helper.ts b/api/tests/helpers/series-helper.ts index 51ab6567..eaa71c9a 100644 --- a/api/tests/helpers/series-helper.ts +++ b/api/tests/helpers/series-helper.ts @@ -15,3 +15,32 @@ export const createSerie = async (serie: SeedSerie) => { const body = await resp.json(); return [resp, body] as const; }; + +export const getEntries = async ( + serie: string, + { + langs, + ...opts + }: { + filter?: string; + limit?: number; + after?: string; + sort?: string | string[]; + query?: string; + langs?: string; + preferOriginal?: boolean; + }, +) => { + const resp = await app.handle( + new Request(buildUrl(`series/${serie}/entries`, opts), { + method: "GET", + headers: langs + ? { + "Accept-Language": langs, + } + : {}, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +};