test/shared/add-on-reachability.test.ts
Rama main · réplica de lectura del repositorio original.
import { expect } from "@std/expect";
import { it as test } from "@std/testing/bdd";
import { withTransaction } from "#db/client.ts";
import { listingChildren } from "#db/listing-parents.ts";
import { t } from "#i18n";
import {
deactivationOrphanedAddOnError,
listingSaveOrphanedAddOnTx,
} from "#shared/add-on-reachability.ts";
import type { ListingInput } from "#shared/catalog-fields/fields.ts";
import { storedInputFor } from "#test/shared/listings-actions/helpers.ts";
import { describeWithEnv } from "#test-utils/db.ts";
import { createTestGroup } from "#test-utils/db-helpers/groups.ts";
import { createTestListing } from "#test-utils/db-helpers/listings.ts";
import {
groupRescuedChildAddOn,
groupScopedAddOn,
linkedParentChild,
linkGroupAddOn,
rescuingPageSetup,
soloChildAddOn,
} from "#test-utils/listing-parents/helpers.ts";
import { optInAddOnForListings } from "#test-utils/modifiers.ts";
const childAddOnError = (name: string): string =>
t("modifiers.err_child_only_addon", { name });
/** The listing save's add-on reachability check, as the row write runs it:
* inside one write transaction, with the guard's reads through it. */
const saveGuard = async (
listingId: number,
overrides: Partial<ListingInput> = {},
): Promise<string | null> => {
const input = await storedInputFor(listingId, overrides);
return withTransaction((tx) =>
listingSaveOrphanedAddOnTx(tx, listingId, input),
);
};
/** Two ordinary pages rescuing one child-scoped opt-in add-on: deactivating
* either page alone leaves the other as the add-on's reachable page. */
const twoRescuingPages = async () => {
const { child, parent } = await linkedParentChild();
const first = await createTestListing({ name: "First rescuing page" });
const second = await createTestListing({ name: "Second rescuing page" });
await optInAddOnForListings("Child-scoped extra", [
child.id,
first.id,
second.id,
]);
return { child, first, parent, second };
};
/** The uncommitted write one concurrent request has made, run through the
* transaction the guard under test reads — the deterministic form of the race
* where the first of two page-removing requests commits first. */
const deactivateListing = (
tx: Parameters<Parameters<typeof withTransaction>[0]>[0],
listingId: number,
): Promise<unknown> =>
tx.execute({
args: [listingId],
sql: "UPDATE listings SET active = 0 WHERE id = ?",
});
describeWithEnv("add-on reachability guards", { db: true }, () => {
test("rejects moving a parent away from its group-scoped child add-on", async () => {
const { child, parent } = await groupScopedAddOn();
await listingChildren.setIds(parent.id, [child.id]);
await expect(saveGuard(parent.id, { groupIds: [] })).resolves.toBe(
t("listings_table.children_err_child_addon_save", {
addon: "Group extra",
}),
);
});
test("rejects moving a child into a group-scoped add-on its parent cannot offer", async () => {
const { child } = await linkedParentChild();
const group = await createTestGroup({ name: "Child Destination" });
await linkGroupAddOn(group.id);
await expect(saveGuard(child.id, { groupIds: [group.id] })).resolves.toBe(
t("listings_table.children_err_child_addon_save", {
addon: "Group extra",
}),
);
});
test("blocks deactivating a standalone child that is its add-on's only page", async () => {
const { child } = await soloChildAddOn();
await expect(
deactivationOrphanedAddOnError(new Set([child.id])),
).resolves.toBe(childAddOnError("Child-only extra"));
});
test("blocks clearing the standalone flag when it is the add-on's only page", async () => {
const { child } = await soloChildAddOn();
await expect(
saveGuard(child.id, { active: true, bookableAlone: false }),
).resolves.toBe(childAddOnError("Child-only extra"));
});
test("blocks deactivating a standalone child through the save guard", async () => {
const { child } = await soloChildAddOn();
await expect(saveGuard(child.id, { active: false })).resolves.toBe(
childAddOnError("Child-only extra"),
);
});
test("blocks an edit that deactivates an ordinary page rescuing a child add-on", async () => {
const { thatPage } = await rescuingPageSetup();
await expect(saveGuard(thatPage.id, { active: false })).resolves.toBe(
childAddOnError("Child-scoped extra"),
);
});
test("blocks moving an ordinary page out of the group its child add-on needs", async () => {
const { rescuer } = await groupRescuedChildAddOn();
await expect(saveGuard(rescuer.id, { groupIds: [] })).resolves.toBe(
childAddOnError("Group extra"),
);
});
test("allows leaving a group when another page still offers its child add-on", async () => {
const { group, rescuer } = await groupRescuedChildAddOn();
await createTestListing({ groupId: group.id, name: "Second page" });
await expect(saveGuard(rescuer.id, { groupIds: [] })).resolves.toBeNull();
});
test("allows saving a standalone child when its page remains available", async () => {
const { child } = await soloChildAddOn();
await expect(saveGuard(child.id)).resolves.toBeNull();
});
test("allows clearing the standalone flag on a listing that is not a child", async () => {
const listing = await createTestListing({
bookableAlone: true,
name: "Ordinary Page",
});
await optInAddOnForListings("Own page extra", [listing.id]);
await expect(
saveGuard(listing.id, { bookableAlone: false }),
).resolves.toBeNull();
});
test("the save guard throws on an update input with no would-be groups", async () => {
const { parent } = await groupScopedAddOn();
const { groupIds: _groupIds, ...input } = await storedInputFor(parent.id);
await expect(
withTransaction((tx) => listingSaveOrphanedAddOnTx(tx, parent.id, input)),
).rejects.toThrow(
`Listing ${parent.id} update carries no would-be group ids`,
);
});
test("refuses the second of two page-removing saves inside its write transaction", async () => {
const { first, second } = await twoRescuingPages();
// The first save alone keeps the add-on reachable through the second page.
await expect(saveGuard(first.id, { active: false })).resolves.toBeNull();
await withTransaction(async (tx) => {
// The first save's row write, still uncommitted: the second save's guard
// runs in a later write transaction, so it must read this row through
// its own transaction to see it.
await deactivateListing(tx, first.id);
await expect(
listingSaveOrphanedAddOnTx(
tx,
second.id,
await storedInputFor(second.id, { active: false }),
),
).resolves.toBe(childAddOnError("Child-scoped extra"));
});
});
test("refuses a bulk deactivation whose co-rescuer a concurrent write took offline", async () => {
const { first, second } = await twoRescuingPages();
// Deactivating one page alone leaves the add-on reachable.
await expect(
deactivationOrphanedAddOnError(new Set([first.id])),
).resolves.toBeNull();
await withTransaction(async (tx) => {
await deactivateListing(tx, first.id);
await expect(
deactivationOrphanedAddOnError(new Set([first.id, second.id]), tx),
).resolves.toBe(childAddOnError("Child-scoped extra"));
});
});
});