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,43 +1,19 @@
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];
}
} catch (e) {
return {
status: 400,
message: `Invalid translation name: '${lang}'.`,
details: null,
};
}
}
return null;
};
// this is just for the doc
FormatRegistry.Set("language", () => true);
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;
}
});
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) =>
export const Language = (props?: NonNullable<Parameters<typeof t.String>[0]>) =>
t
.Transform(
t.String({
format: "language",
description: comment`
@ -47,7 +23,47 @@ export const Language = (props?: StringProps) =>
`,
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}'`);
}
})
.Encode((x) => x);
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);
// 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({
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);