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