Handle fallback when creating new translations

This commit is contained in:
Zoe Roux 2025-01-14 19:17:35 +01:00
parent 4d0a6e5223
commit 7cc6e7e2d4
5 changed files with 180 additions and 73 deletions

View File

@ -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`

View File

@ -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" }))),
}),

View File

@ -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 = <T extends object>(
translations: Record<string, T>,
): 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<Parameters<typeof t.String>[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 = <T extends TSchema>(
values: Parameters<typeof t.Record<TString, T>>[1],
props?: Parameters<typeof t.Record<TString, T>>[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<string, StaticDecode<T>>) => {
for (const lang of Object.keys(translations)) {
try {
const locale = new Intl.Locale(lang);
type StringProps = NonNullable<Parameters<typeof t.String>[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 ["*"];

View File

@ -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,

View File

@ -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);