From 2cd63c49fc8c6d13134e2f4de0c1911477f98218 Mon Sep 17 00:00:00 2001
From: Claude {t("catalog_transfer.description")}
))}
diff --git a/test/lib/listings-actions.test.ts b/test/lib/listings-actions.test.ts
index 89b695d32e..0f7022ed7c 100644
--- a/test/lib/listings-actions.test.ts
+++ b/test/lib/listings-actions.test.ts
@@ -5,7 +5,13 @@ import {
listingInputToEdge,
validateListingInput,
} from "#shared/listings-actions.ts";
-import { setupTestEncryptionKey, testListingInput } from "#test-utils";
+import {
+ createTestGroup,
+ createTestListing,
+ describeWithEnv,
+ setupTestEncryptionKey,
+ testListingInput,
+} from "#test-utils";
setupTestEncryptionKey();
@@ -44,7 +50,11 @@ describe("listingInputToEdge", () => {
});
});
-describe("validateListingInput", () => {
+// validateListingInput now reads the catalog (for cross-entity name
+// uniqueness), so these cases run against an empty test DB — no listing/group
+// shares these names, so the uniqueness check passes and each case exercises
+// the specific rule it names.
+describeWithEnv("validateListingInput", { db: true }, () => {
test("rejects assignBuiltSite with initialSiteMonths <= 0", async () => {
const input: ListingInput = {
...testListingInput({
@@ -173,4 +183,41 @@ describe("validateListingInput", () => {
});
await expect(validateListingInput(input)).resolves.toBeNull();
});
+
+ const NAME_IN_USE = "Name is already in use by another listing or group";
+
+ const namedInput = (name: string): ListingInput => ({
+ ...testListingInput({ name }),
+ slug: "some-slug",
+ slugIndex: "some-index",
+ });
+
+ test("rejects a create whose name is used by an existing listing", async () => {
+ await createTestListing({ name: "Taken Name" });
+ await expect(validateListingInput(namedInput("Taken Name"))).resolves.toBe(
+ NAME_IN_USE,
+ );
+ });
+
+ test("rejects a create whose name is used by a group", async () => {
+ await createTestGroup({ name: "Group Name" });
+ await expect(validateListingInput(namedInput("Group Name"))).resolves.toBe(
+ NAME_IN_USE,
+ );
+ });
+
+ test("lets a listing keep its own name on edit", async () => {
+ const listing = await createTestListing({ name: "Mine" });
+ await expect(
+ validateListingInput(namedInput("Mine"), listing.id),
+ ).resolves.toBeNull();
+ });
+
+ test("rejects renaming a listing to another listing's name", async () => {
+ const first = await createTestListing({ name: "First" });
+ const second = await createTestListing({ name: "Second" });
+ await expect(
+ validateListingInput(namedInput(first.name), second.id),
+ ).resolves.toBe(NAME_IN_USE);
+ });
});
diff --git a/test/lib/name-registry.test.ts b/test/lib/name-registry.test.ts
new file mode 100644
index 0000000000..5d42f98111
--- /dev/null
+++ b/test/lib/name-registry.test.ts
@@ -0,0 +1,111 @@
+import { expect } from "@std/expect";
+import { it as test } from "@std/testing/bdd";
+import { computeSlugIndex, listingsTable } from "#shared/db/listings.ts";
+import {
+ isNameTakenAnywhere,
+ loadCatalogNameIndex,
+ matchName,
+ normalizeEntityName,
+} from "#shared/db/name-registry.ts";
+import {
+ createTestGroup,
+ createTestListing,
+ describeWithEnv,
+} from "#test-utils";
+
+describeWithEnv("name-registry", { db: true }, () => {
+ test("normalizeEntityName trims and case-folds", () => {
+ expect(normalizeEntityName(" Weekend Pass ")).toBe("weekend pass");
+ expect(normalizeEntityName("WEEKEND pass")).toBe("weekend pass");
+ });
+
+ test("a name is free on an empty catalog", async () => {
+ expect(await isNameTakenAnywhere("Anything")).toBe(false);
+ });
+
+ test("a listing name is taken by that listing", async () => {
+ await createTestListing({ name: "Solo Show" });
+ expect(await isNameTakenAnywhere("Solo Show")).toBe(true);
+ // Case- and whitespace-insensitive, so an import can't smuggle a near-dup.
+ expect(await isNameTakenAnywhere(" solo show ")).toBe(true);
+ });
+
+ test("a listing name collides with a group of the same name", async () => {
+ await createTestGroup({ name: "Shared Name" });
+ // A LISTING may not take a GROUP's name — the two share one namespace.
+ expect(await isNameTakenAnywhere("Shared Name")).toBe(true);
+ });
+
+ test("a group name collides with a listing of the same name", async () => {
+ await createTestListing({ name: "Overlap" });
+ expect(
+ await isNameTakenAnywhere("Overlap", { id: 99999, kind: "group" }),
+ ).toBe(true);
+ });
+
+ test("excluding the owning row lets it keep its own name", async () => {
+ const listing = await createTestListing({ name: "Keep Me" });
+ expect(
+ await isNameTakenAnywhere("Keep Me", { id: listing.id, kind: "listing" }),
+ ).toBe(false);
+ // Excluding the wrong kind/id does not free the name.
+ expect(
+ await isNameTakenAnywhere("Keep Me", { id: listing.id, kind: "group" }),
+ ).toBe(true);
+ });
+
+ test("a blank or whitespace-only name is never taken", async () => {
+ expect(await isNameTakenAnywhere("")).toBe(false);
+ expect(await isNameTakenAnywhere(" ")).toBe(false);
+ });
+
+ test("matchName resolves a unique listing and group by name", async () => {
+ const listing = await createTestListing({ name: "Findable" });
+ const group = await createTestGroup({ name: "Group One" });
+ const index = await loadCatalogNameIndex();
+ expect(matchName(index.listing, "findable")).toEqual({
+ id: listing.id,
+ ok: true,
+ });
+ expect(matchName(index.group, " Group One ")).toEqual({
+ id: group.id,
+ ok: true,
+ });
+ });
+
+ test("matchName reports a missing name", async () => {
+ const index = await loadCatalogNameIndex();
+ expect(matchName(index.listing, "Ghost")).toEqual({
+ ok: false,
+ reason: "missing",
+ });
+ });
+
+ test("matchName reports an ambiguous legacy duplicate", async () => {
+ // Insert straight through the table (bypassing the uniqueness validator) to
+ // simulate legacy data that predates the rule: two listings, one name.
+ const slugA = "dup-a";
+ const slugB = "dup-b";
+ await listingsTable.insert({
+ maxAttendees: 1,
+ maxPrice: 0,
+ name: "Twin",
+ slug: slugA,
+ slugIndex: await computeSlugIndex(slugA),
+ });
+ await listingsTable.insert({
+ maxAttendees: 1,
+ maxPrice: 0,
+ name: "Twin",
+ slug: slugB,
+ slugIndex: await computeSlugIndex(slugB),
+ });
+ const index = await loadCatalogNameIndex();
+ expect(matchName(index.listing, "Twin")).toEqual({
+ ok: false,
+ reason: "ambiguous",
+ });
+ // And the ambiguous name reads as taken for uniqueness purposes.
+ expect(await isNameTakenAnywhere("Twin")).toBe(true);
+ });
+});
diff --git a/test/lib/server-groups.test.ts b/test/lib/server-groups.test.ts
index a0fa05417a..29dd4b2220 100644
--- a/test/lib/server-groups.test.ts
+++ b/test/lib/server-groups.test.ts
@@ -146,6 +146,45 @@ describeWithEnv("server (admin groups)", { db: true }, () => {
expect(group.description).toBe("A fun group of listings");
});
+ const NAME_IN_USE = "Name is already in use by another listing or group";
+
+ test("rejects a group whose name is used by a listing", async () => {
+ await createTestListing({ name: "Clash Name" });
+ const { response } = await adminFormPost("/admin/groups", {
+ name: "Clash Name",
+ terms_and_conditions: "",
+ });
+ await expectFlashRedirect(
+ "/admin/groups/new",
+ NAME_IN_USE,
+ false,
+ )(response);
+ });
+
+ test("rejects a group whose name is used by another group", async () => {
+ await createTestGroup({ name: "Twin Group" });
+ const { response } = await adminFormPost("/admin/groups", {
+ name: "Twin Group",
+ terms_and_conditions: "",
+ });
+ await expectFlashRedirect(
+ "/admin/groups/new",
+ NAME_IN_USE,
+ false,
+ )(response);
+ });
+
+ test("lets a group keep its own name on edit", async () => {
+ const group = await createTestGroup({ name: "Renamer" });
+ // Re-saving the group under its own name must not trip the uniqueness
+ // check against itself.
+ const updated = await updateTestGroup(group.id, {
+ name: "Renamer",
+ slug: group.slug,
+ });
+ expect(updated.name).toBe("Renamer");
+ });
+
test("creates group without description defaults to empty string", async () => {
const group = await createTestGroup({ name: "No Desc Group" });
expect(group.description).toBe("");
diff --git a/test/lib/server-listings.test.ts b/test/lib/server-listings.test.ts
index bd8d297368..be8183d917 100644
--- a/test/lib/server-listings.test.ts
+++ b/test/lib/server-listings.test.ts
@@ -346,23 +346,26 @@ describeWithEnv("server (admin listings)", { db: true }, () => {
);
});
- test("rejects duplicate slug", async () => {
- // First, create an listing with a specific name
+ test("rejects a duplicate listing name", async () => {
+ // First, create a listing with a specific name
await setupListingAndLogin({
maxAttendees: 100,
name: "Duplicate Listing",
thankYouUrl: "https://example.com",
});
- // Try to create another listing with the same name (generates same slug)
+ // A second listing may not reuse the name — names are unique across the
+ // catalog so listings/groups can be referenced by name for import/export.
const { response } = await adminMultipartPost("/admin/listing", {
max_attendees: "50",
max_quantity: "1",
name: "Duplicate Listing",
thank_you_url: "https://example.com",
});
- // Slug auto-generated so creation succeeds
- await expectFlashRedirect("/admin", "Listing created")(response);
+ expect(response.status).toBe(400);
+ expect(await response.text()).toContain(
+ "Name is already in use by another listing or group",
+ );
});
});
From e461f3aef4735001560559dc590c38ade1111c21 Mon Sep 17 00:00:00 2001
From: Claude {t(row.labelKey)}
-
-
+ {summaryValue(row)}
{t("catalog_transfer.heading")}
+
+