diff --git a/api/src/controllers/seed/index.ts b/api/src/controllers/seed/index.ts index 9926f37d..039f5315 100644 --- a/api/src/controllers/seed/index.ts +++ b/api/src/controllers/seed/index.ts @@ -1,7 +1,8 @@ import Elysia from "elysia"; +import { Value } from "@sinclair/typebox/value"; import { Movie, SeedMovie } from "~/models/movie"; import { seedMovie, SeedMovieResponse } from "./movies"; -import { Resource, validateTranslations } from "~/models/utils"; +import { Resource } from "~/models/utils"; import { comment } from "~/utils"; import { KError } from "~/models/error"; @@ -14,8 +15,8 @@ export const seed = new Elysia() .post( "/movies", async ({ body, error }) => { - const err = validateTranslations(body.translations); - if (err) return error(400, err); + // needed due to https://github.com/elysiajs/elysia/issues/671 + body = Value.Decode(SeedMovie, body); const ret = await seedMovie(body); if (ret.status === 422) return error(422, ret); @@ -29,7 +30,6 @@ export const seed = new Elysia() description: "Existing movie edited/updated.", }, 201: { ...SeedMovieResponse, description: "Created a new movie." }, - 400: { ...KError, description: "Invalid translation name" }, 409: { ...Resource, description: comment` diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 4fbf8623..a759da00 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -1,5 +1,5 @@ import { t } from "elysia"; -import { ExternalId, Genre, Image, Language, SeedImage } from "./utils"; +import { ExternalId, Genre, Image, Language, SeedImage, TranslationRecord } from "./utils"; import { bubble, registerExamples } from "./examples"; import { bubbleImages } from "./examples/bubble"; @@ -50,8 +50,7 @@ export type Movie = typeof Movie.static; export const SeedMovie = t.Intersect([ t.Omit(BaseMovie, ["id", "createdAt", "nextRefresh"]), t.Object({ - translations: t.Record( - Language(), + translations: TranslationRecord( t.Intersect([ t.Omit(MovieTranslation, ["poster", "thumbnail", "banner", "logo"]), t.Object({ @@ -61,9 +60,6 @@ export const SeedMovie = t.Intersect([ logo: t.Nullable(SeedImage), }), ]), - { - minProperties: 1, - }, ), videos: t.Optional(t.Array(t.String({ format: "uuid" }))), }), diff --git a/api/src/models/utils/language.ts b/api/src/models/utils/language.ts index afc58ea1..66f87cf7 100644 --- a/api/src/models/utils/language.ts +++ b/api/src/models/utils/language.ts @@ -1,53 +1,69 @@ -import { FormatRegistry } from "@sinclair/typebox"; +import { + FormatRegistry, + StaticDecode, + TSchema, + TString, +} from "@sinclair/typebox"; import { t } from "elysia"; import { comment } from "../../utils"; -import type { KError } from "../error"; +import { KErrorT } from "../error"; -export const validateTranslations = ( - translations: Record, -): KError | null => { - for (const lang of Object.keys(translations)) { - try { - const valid = new Intl.Locale(lang).baseName; - if (lang !== valid) { - translations[valid] = translations[lang]; - delete translations[lang]; +// this is just for the doc +FormatRegistry.Set("language", () => true); + +export const Language = (props?: NonNullable[0]>) => + t + .Transform( + t.String({ + format: "language", + description: comment` + ${props?.description ?? ""} + This is a BCP 47 language code (the IETF Best Current Practices on Tags for Identifying Languages). + BCP 47 is also known as RFC 5646. It subsumes ISO 639 and is backward compatible with it. + `, + error: "Expected a valid (and NORMALIZED) bcp-47 language code.", + ...props, + }), + ) + .Decode((lang) => { + try { + return new Intl.Locale(lang).baseName; + } catch { + throw new KErrorT(`Invalid language name: '${lang}'`); } - } catch (e) { - return { - status: 400, - message: `Invalid translation name: '${lang}'.`, - details: null, - }; - } - } - return null; -}; + }) + .Encode((x) => x); -FormatRegistry.Set("language", (lang) => { - try { - const normalized = new Intl.Locale(lang).baseName; - // TODO: we should actually replace the locale with normalized if we managed to parse it but transforms aren't working - return lang === normalized; - } catch { - return false; - } -}); +export const TranslationRecord = ( + values: Parameters>[1], + props?: Parameters>[2], +) => + t + .Transform(t.Record(t.String(), values, { minPropreties: 1, ...props })) + // @ts-expect-error idk why the translations type can't get resolved so it's a pain to work + // with without casting it + .Decode((translations: Record>) => { + for (const lang of Object.keys(translations)) { + try { + const locale = new Intl.Locale(lang); -type StringProps = NonNullable[0]>; - -// TODO: format validation doesn't work in record's key. We should have a proper way to check that. -export const Language = (props?: StringProps) => - t.String({ - format: "language", - description: comment` - ${props?.description ?? ""} - This is a BCP 47 language code (the IETF Best Current Practices on Tags for Identifying Languages). - BCP 47 is also known as RFC 5646. It subsumes ISO 639 and is backward compatible with it. - `, - error: "Expected a valid (and NORMALIZED) bcp-47 language code.", - ...props, - }); + // fallback (ex add `en` if we only have `en-us`) + if (!(locale.language in translations)) + translations[locale.language] = translations[lang]; + // normalize locale names (caps, old values etc) + // we need to do this here because the record's key (Language)'s transform is not runned. + // this is a limitation of typebox + if (lang !== locale.baseName) { + translations[locale.baseName] = translations[lang]; + delete translations[lang]; + } + } catch (e) { + throw new KErrorT(`Invalid translation name: '${lang}'.`); + } + } + return translations; + }) + .Encode((x) => x); export const processLanguages = (languages?: string) => { if (!languages) return ["*"]; diff --git a/api/tests/movies/get-movie.test.ts b/api/tests/movies/get-movie.test.ts index d43b39b2..2fa27abd 100644 --- a/api/tests/movies/get-movie.test.ts +++ b/api/tests/movies/get-movie.test.ts @@ -1,8 +1,5 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { eq } from "drizzle-orm"; +import { beforeAll, describe, expect, it } from "bun:test"; import { seedMovie } from "~/controllers/seed/movies"; -import { db } from "~/db"; -import { shows } from "~/db/schema"; import { bubble } from "~/models/examples"; import { getMovie } from "./movies-helper"; import { expectStatus } from "tests/utils"; @@ -11,10 +8,7 @@ let bubbleId = ""; beforeAll(async () => { const ret = await seedMovie(bubble); - bubbleId = ret.id; -}); -afterAll(async () => { - await db.delete(shows).where(eq(shows.slug, bubble.slug)); + if (ret.status !== 422) bubbleId = ret.id; }); describe("Get movie", () => { @@ -68,6 +62,16 @@ describe("Get movie", () => { it("Works without accept-language header", async () => { const [resp, body] = await getMovie(bubble.slug, undefined); + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + slug: bubble.slug, + name: bubble.translations.en.name, + }); + expect(resp.headers.get("Content-Language")).toBe("en"); + }); + it("Fallback if translations does not exist", async () => { + const [resp, body] = await getMovie(bubble.slug, "en-au"); + expectStatus(resp, body).toBe(200); expect(body).toMatchObject({ slug: bubble.slug, diff --git a/api/tests/movies/seed-movies.test.ts b/api/tests/movies/seed-movies.test.ts index 827072d3..32231b60 100644 --- a/api/tests/movies/seed-movies.test.ts +++ b/api/tests/movies/seed-movies.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; -import { eq, inArray } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { expectStatus } from "tests/utils"; import { db } from "~/db"; import { shows, showTranslations, videos } from "~/db/schema"; @@ -154,8 +154,8 @@ describe("Movie seeding", () => { }, }); - expectStatus(resp, body).toBe(400); - expect(body.status).toBe(400); + expectStatus(resp, body).toBe(422); + expect(body.status).toBe(422); expect(body.message).toBe("Invalid translation name: 'test'."); }); @@ -163,6 +163,7 @@ describe("Movie seeding", () => { const [resp, body] = await createMovie({ ...bubble, slug: "casing-test", + originalLanguage: "jp-jp", translations: { "en-us": { name: "foo", @@ -185,15 +186,32 @@ describe("Movie seeding", () => { where: eq(shows.id, body.id), with: { translations: true }, }); - expect(ret!.translations).toBeArrayOfSize(1); - expect(ret!.translations[0]).toMatchObject({ - language: "en-US", - name: "foo", - }); + 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 }); + 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 () => { @@ -202,14 +220,87 @@ describe("Movie seeding", () => { 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", + }), + ]), + ); + }); + test.todo("Create correct video slug (version)", async () => {}); test.todo("Create correct video slug (part)", async () => {}); test.todo("Create correct video slug (rendering)", async () => {}); }); const cleanup = async () => { - await db.delete(shows).where(inArray(shows.slug, [dune.slug, "dune-2158"])); - await db.delete(videos).where(inArray(videos.id, [duneVideo.id])); + await db.delete(shows); + await db.delete(videos); }; // cleanup db beforehand to unsure tests are consistent beforeAll(cleanup);