Handle and test slug reconciliation & conflicts

This commit is contained in:
Zoe Roux 2024-12-06 20:55:51 +01:00
parent caa394e0da
commit c8c6cccf6a
No known key found for this signature in database
3 changed files with 104 additions and 38 deletions

View File

@ -1,6 +1,8 @@
import Elysia, { t } from "elysia"; import Elysia, { t } from "elysia";
import { Movie, SeedMovie } from "~/models/movie"; import { Movie, SeedMovie } from "~/models/movie";
import { seedMovie, SeedMovieResponse } from "./movies"; import { seedMovie, SeedMovieResponse } from "./movies";
import { Resource } from "~/models/utils";
import { comment } from "~/utils";
export const seed = new Elysia() export const seed = new Elysia()
.model({ .model({
@ -13,7 +15,7 @@ export const seed = new Elysia()
"/movies", "/movies",
async ({ body, error }) => { async ({ body, error }) => {
const { status, ...ret } = await seedMovie(body); const { status, ...ret } = await seedMovie(body);
return error(status === "created" ? 201 : 200, ret); return error(status, ret);
}, },
{ {
body: "seed-movie", body: "seed-movie",
@ -24,6 +26,13 @@ export const seed = new Elysia()
}, },
201: { ...SeedMovieResponse, description: "Created a new movie." }, 201: { ...SeedMovieResponse, description: "Created a new movie." },
400: "error", 400: "error",
409: {
...Resource,
description: comment`
A movie with the same slug but a different air date already exists.
Change the slug and re-run the request.
`,
},
}, },
detail: { detail: {
tags: ["movies"], tags: ["movies"],

View File

@ -1,4 +1,4 @@
import { inArray, sql } from "drizzle-orm"; import { inArray, sql, eq } from "drizzle-orm";
import { t } from "elysia"; import { t } from "elysia";
import { db } from "~/db"; import { db } from "~/db";
import { import {
@ -29,7 +29,9 @@ export type SeedMovieResponse = typeof SeedMovieResponse.static;
export const seedMovie = async ( export const seedMovie = async (
seed: SeedMovie, seed: SeedMovie,
): Promise<SeedMovieResponse & { status: "created" | "updated" }> => { ): Promise<
SeedMovieResponse & { status: "Created" | "OK" | "Conflict" }
> => {
const { translations, videos: vids, ...bMovie } = seed; const { translations, videos: vids, ...bMovie } = seed;
const ret = await db.transaction(async (tx) => { const ret = await db.transaction(async (tx) => {
@ -39,29 +41,57 @@ export const seedMovie = async (
nextRefresh: guessNextRefresh(bMovie.airDate ?? new Date()), nextRefresh: guessNextRefresh(bMovie.airDate ?? new Date()),
...bMovie, ...bMovie,
}; };
const [ret] = await tx
.insert(shows) const insert = () =>
.values(movie) tx
.onConflictDoUpdate({ .insert(shows)
target: shows.slug, .values(movie)
set: conflictUpdateAllExcept(shows, ["pk", "id", "slug", "createdAt"]), .onConflictDoUpdate({
// if year is different, this is not an update but a conflict (ex: dune-1984 vs dune-2021) target: shows.slug,
setWhere: sql`date_part('year', ${shows.startAir}) = date_part('year', excluded."start_air")`, set: conflictUpdateAllExcept(shows, [
}) "pk",
.returning({ "id",
pk: shows.pk, "slug",
id: shows.id, "createdAt",
slug: shows.slug, ]),
startAir: shows.startAir, // if year is different, this is not an update but a conflict (ex: dune-1984 vs dune-2021)
// https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667 setWhere: sql`date_part('year', ${shows.startAir}) = date_part('year', excluded."start_air")`,
updated: sql<boolean>`(xmax <> 0)`.as("updated"), })
}); .returning({
if (ret.updated) { pk: shows.pk,
// TODO: if updated, differenciates updates with conflicts. id: shows.id,
// if the start year is different or external ids, it's a conflict. slug: shows.slug,
// if (getYear(ret.startAir) === getYear(movie.startAir)) { // https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667
// return; updated: sql<boolean>`(xmax <> 0)`.as("updated"),
// } });
let [ret] = await insert();
if (!ret) {
// ret is undefined when the conflict's where return false (meaning we have
// a conflicting slug but a different air year.
// try to insert adding the year at the end of the slug.
if (
movie.startAir &&
!movie.slug.endsWith(`${getYear(movie.startAir)}`)
) {
movie.slug = `${movie.slug}-${getYear(movie.startAir)}`;
[ret] = await insert();
}
// if at this point ret is still undefined, we could not reconciliate.
// simply bail and let the caller handle this.
if (!ret) {
const [{ id }] = await db
.select({ id: shows.id })
.from(shows)
.where(eq(shows.slug, movie.slug))
.limit(1);
return {
status: "Conflict" as const,
id,
slug: movie.slug,
videos: [],
};
}
} }
// even if never shown to the user, a movie still has an entry. // even if never shown to the user, a movie still has an entry.
@ -112,6 +142,8 @@ export const seedMovie = async (
return { ...ret, entry: entry.pk }; return { ...ret, entry: entry.pk };
}); });
if (ret.status === "Conflict") return ret;
let retVideos: { slug: string }[] = []; let retVideos: { slug: string }[] = [];
if (vids) { if (vids) {
retVideos = await db retVideos = await db
@ -139,14 +171,13 @@ export const seedMovie = async (
} }
return { return {
status: ret.updated ? "updated" : "created", status: ret.updated ? "Ok" : "Created",
id: ret.id, id: ret.id,
slug: ret.slug, slug: ret.slug,
videos: retVideos, videos: retVideos,
}; };
}; };
function getYear(date?: string | null) { function getYear(date: string) {
if (!date) return null;
return new Date(date).getUTCFullYear(); return new Date(date).getUTCFullYear();
} }

View File

@ -54,7 +54,7 @@ describe("Movie seeding", () => {
const [resp, body] = await createMovie({ const [resp, body] = await createMovie({
...dune, ...dune,
airDate: "2159-12-09", runtime: 200_000,
translations: { translations: {
...dune.translations, ...dune.translations,
en: { ...dune.translations.en, description: "edited translation" }, en: { ...dune.translations.en, description: "edited translation" },
@ -85,23 +85,49 @@ describe("Movie seeding", () => {
expectStatus(resp, body).toBe(200); expectStatus(resp, body).toBe(200);
expect(body.id).toBeString(); expect(body.id).toBeString();
expect(body.slug).toBe("dune"); expect(body.slug).toBe("dune");
expect(body.videos).toBe([]); expect(body.videos).toBeArrayOfSize(0);
expect(edited.startAir).toBe("2159-12-09"); expect(edited.runtime).toBe(200_000);
expect(edited.status).toBe(dune.status); expect(edited.status).toBe(dune.status);
expect(translations).toMatchObject({ expect(translations.find((x) => x.language === "en")).toMatchObject({
language: "en",
name: dune.translations.en.name, name: dune.translations.en.name,
description: "edited translation", description: "edited translation",
}); });
expect(translations).toMatchObject({ expect(translations.find((x) => x.language === "fr")).toMatchObject({
language: "fr",
name: "dune-but-in-french", name: "dune-but-in-french",
description: null, description: null,
}); });
}); });
test.todo("Conflicting slug auto-correct", async () => {}); it("Conflicting slug auto-correct", async () => {
test.todo("Conflict in slug+year fails", 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(200);
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);
});
test.todo("Missing videos send info", async () => {}); test.todo("Missing videos send info", async () => {});
test.todo("Schema error", async () => {}); test.todo("Schema error", async () => {});
test.todo("Invalid translation name", async () => {}); test.todo("Invalid translation name", async () => {});