Skip to content
Merged

Test #678

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/api/catalogs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { createCatalogHandler } from "@/lib/catalog/createCatalogHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* POST handler for creating a catalog (optionally materialized from a
* valuation snapshot).
*
* @param request - The request object containing the catalog body.
* @returns A NextResponse with the created catalog.
*/
export async function POST(request: NextRequest) {
return createCatalogHandler(request);
}
160 changes: 160 additions & 0 deletions lib/catalog/__tests__/createCatalogHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

import { createCatalogHandler } from "../createCatalogHandler";
import { validateCreateCatalogBody } from "../validateCreateCatalogBody";
import { createSnapshotCatalog } from "../createSnapshotCatalog";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { selectPlaycountSnapshots } from "@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots";
import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById";
import { insertCatalog } from "@/lib/supabase/catalogs/insertCatalog";
import { insertAccountCatalog } from "@/lib/supabase/account_catalogs/insertAccountCatalog";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));
vi.mock("../validateCreateCatalogBody", () => ({ validateCreateCatalogBody: vi.fn() }));
vi.mock("../createSnapshotCatalog", () => ({ createSnapshotCatalog: vi.fn() }));
vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() }));
vi.mock("@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots", () => ({
selectPlaycountSnapshots: vi.fn(),
}));
vi.mock("@/lib/supabase/catalogs/selectCatalogById", () => ({ selectCatalogById: vi.fn() }));
vi.mock("@/lib/supabase/catalogs/insertCatalog", () => ({ insertCatalog: vi.fn() }));
vi.mock("@/lib/supabase/account_catalogs/insertAccountCatalog", () => ({
insertAccountCatalog: vi.fn(),
}));

const accountId = "550e8400-e29b-41d4-a716-446655440000";
const otherAccountId = "550e8400-e29b-41d4-a716-446655440999";
const snapshotId = "11111111-2222-3333-4444-555555555555";
const catalogId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";

const catalog = {
id: catalogId,
name: "Bad Bunny — Catalog",
created_at: "2026-06-18T00:00:00Z",
updated_at: "2026-06-18T00:00:00Z",
};

const makeRequest = () => new NextRequest("http://localhost/api/catalogs", { method: "POST" });

const okAuth = () =>
vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null, authToken: "t" });

describe("createCatalogHandler", () => {
beforeEach(() => vi.clearAllMocks());

it("short-circuits with the validator error and never authenticates", async () => {
const err = NextResponse.json({ status: "error" }, { status: 400 });
vi.mocked(validateCreateCatalogBody).mockReturnValue(err);

const res = await createCatalogHandler(makeRequest());

expect(res).toBe(err);
expect(validateAuthContext).not.toHaveBeenCalled();
});

it("short-circuits with the auth error when unauthenticated", async () => {
vi.mocked(validateCreateCatalogBody).mockReturnValue({ name: "X" });
const authErr = NextResponse.json({ status: "error" }, { status: 401 });
vi.mocked(validateAuthContext).mockResolvedValue(authErr);

const res = await createCatalogHandler(makeRequest());

expect(res).toBe(authErr);
expect(insertCatalog).not.toHaveBeenCalled();
});

it("creates an empty catalog from a name-only body and links the account", async () => {
vi.mocked(validateCreateCatalogBody).mockReturnValue({ name: "Bad Bunny — Catalog" });
okAuth();
vi.mocked(insertCatalog).mockResolvedValue(catalog);

const res = await createCatalogHandler(makeRequest());
const body = await res.json();

expect(res.status).toBe(200);
expect(insertCatalog).toHaveBeenCalledWith("Bad Bunny — Catalog");
expect(insertAccountCatalog).toHaveBeenCalledWith({ account: accountId, catalog: catalogId });
expect(body).toEqual({ status: "success", catalog, songs_added: 0 });
expect(selectPlaycountSnapshots).not.toHaveBeenCalled();
});

it("materializes a catalog from an owned, not-yet-claimed snapshot", async () => {
vi.mocked(validateCreateCatalogBody).mockReturnValue({
name: "Bad Bunny — Catalog",
snapshot: snapshotId,
});
okAuth();
vi.mocked(selectPlaycountSnapshots).mockResolvedValue([
{ id: snapshotId, account: accountId, catalog: null, isrcs: ["A", "B"] } as never,
]);
vi.mocked(createSnapshotCatalog).mockResolvedValue({ catalog, songsAdded: 2 });

const res = await createCatalogHandler(makeRequest());
const body = await res.json();

expect(res.status).toBe(200);
expect(createSnapshotCatalog).toHaveBeenCalledWith({
accountId,
snapshot: expect.objectContaining({ id: snapshotId }),
name: "Bad Bunny — Catalog",
});
expect(body).toEqual({ status: "success", catalog, songs_added: 2 });
});

it("returns 404 when the snapshot does not exist", async () => {
vi.mocked(validateCreateCatalogBody).mockReturnValue({ snapshot: snapshotId });
okAuth();
vi.mocked(selectPlaycountSnapshots).mockResolvedValue([]);

const res = await createCatalogHandler(makeRequest());

expect(res.status).toBe(404);
expect(createSnapshotCatalog).not.toHaveBeenCalled();
});

it("returns 403 when the snapshot belongs to a different account", async () => {
vi.mocked(validateCreateCatalogBody).mockReturnValue({ snapshot: snapshotId });
okAuth();
vi.mocked(selectPlaycountSnapshots).mockResolvedValue([
{ id: snapshotId, account: otherAccountId, catalog: null, isrcs: ["A"] } as never,
]);

const res = await createCatalogHandler(makeRequest());

expect(res.status).toBe(403);
expect(createSnapshotCatalog).not.toHaveBeenCalled();
});

it("is idempotent: returns the existing catalog when the snapshot is already claimed", async () => {
vi.mocked(validateCreateCatalogBody).mockReturnValue({ snapshot: snapshotId });
okAuth();
vi.mocked(selectPlaycountSnapshots).mockResolvedValue([
{ id: snapshotId, account: accountId, catalog: catalogId, isrcs: ["A", "B"] } as never,
]);
vi.mocked(selectCatalogById).mockResolvedValue(catalog);

const res = await createCatalogHandler(makeRequest());
const body = await res.json();

expect(res.status).toBe(200);
expect(selectCatalogById).toHaveBeenCalledWith(catalogId);
expect(createSnapshotCatalog).not.toHaveBeenCalled();
expect(body).toEqual({ status: "success", catalog, songs_added: 0 });
});

it("returns a generic 500 without leaking the underlying error", async () => {
vi.mocked(validateCreateCatalogBody).mockReturnValue({ name: "X" });
okAuth();
vi.mocked(insertCatalog).mockRejectedValue(new Error("db down at 10.0.0.1:5432"));

const res = await createCatalogHandler(makeRequest());
const body = await res.json();

expect(res.status).toBe(500);
expect(body.status).toBe("error");
expect(JSON.stringify(body)).not.toContain("10.0.0.1");
});
});
92 changes: 92 additions & 0 deletions lib/catalog/__tests__/createSnapshotCatalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

import { createSnapshotCatalog } from "../createSnapshotCatalog";
import { insertCatalog } from "@/lib/supabase/catalogs/insertCatalog";
import { insertAccountCatalog } from "@/lib/supabase/account_catalogs/insertAccountCatalog";
import { insertCatalogSongs } from "@/lib/supabase/catalog_songs/insertCatalogSongs";
import { updatePlaycountSnapshot } from "@/lib/supabase/playcount_snapshots/updatePlaycountSnapshot";
import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements";

vi.mock("@/lib/supabase/catalogs/insertCatalog", () => ({ insertCatalog: vi.fn() }));
vi.mock("@/lib/supabase/account_catalogs/insertAccountCatalog", () => ({
insertAccountCatalog: vi.fn(),
}));
vi.mock("@/lib/supabase/catalog_songs/insertCatalogSongs", () => ({ insertCatalogSongs: vi.fn() }));
vi.mock("@/lib/supabase/playcount_snapshots/updatePlaycountSnapshot", () => ({
updatePlaycountSnapshot: vi.fn(),
}));
vi.mock("@/lib/supabase/song_measurements/selectSongMeasurements", () => ({
selectSongMeasurements: vi.fn(),
}));

const accountId = "550e8400-e29b-41d4-a716-446655440000";
const snapshotId = "11111111-2222-3333-4444-555555555555";
const catalogId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const catalog = { id: catalogId, name: "Bad Bunny Catalog", created_at: "t", updated_at: "t" };
// A real valuation snapshot is scoped by album_ids, so its own `isrcs` column is null —
// the measured ISRCs live in song_measurements (sourced via selectSongMeasurements).
const snapshot = { id: snapshotId, account: accountId, catalog: null, isrcs: null } as never;
const measurement = (song: string) => ({ song }) as never;

describe("createSnapshotCatalog", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(insertCatalog).mockResolvedValue(catalog);
});

it("sources measured ISRCs from song_measurements (by snapshot) and adds them as catalog songs", async () => {
vi.mocked(selectSongMeasurements).mockResolvedValue([
measurement("ISRC_A"),
measurement("ISRC_B"),
measurement("ISRC_C"),
]);

const result = await createSnapshotCatalog({ accountId, snapshot, name: "Bad Bunny Catalog" });

expect(insertCatalog).toHaveBeenCalledWith("Bad Bunny Catalog");
expect(insertAccountCatalog).toHaveBeenCalledWith({ account: accountId, catalog: catalogId });
// ISRCs come from measurements by snapshot, NOT snapshot.isrcs (null here)
expect(selectSongMeasurements).toHaveBeenCalledWith({ snapshot: snapshotId });
expect(insertCatalogSongs).toHaveBeenCalledWith([
{ catalog: catalogId, song: "ISRC_A" },
{ catalog: catalogId, song: "ISRC_B" },
{ catalog: catalogId, song: "ISRC_C" },
]);
expect(updatePlaycountSnapshot).toHaveBeenCalledWith(snapshotId, { catalog: catalogId });
expect(result).toEqual({ catalog, songsAdded: 3 });
});

it("dedupes ISRCs across multiple measurement rows per track", async () => {
vi.mocked(selectSongMeasurements).mockResolvedValue([
measurement("ISRC_A"),
measurement("ISRC_A"),
measurement("ISRC_B"),
]);

const result = await createSnapshotCatalog({ accountId, snapshot });

expect(insertCatalogSongs).toHaveBeenCalledWith([
{ catalog: catalogId, song: "ISRC_A" },
{ catalog: catalogId, song: "ISRC_B" },
]);
expect(result).toEqual({ catalog, songsAdded: 2 });
});

it("adds no songs when the snapshot has no measurements", async () => {
vi.mocked(selectSongMeasurements).mockResolvedValue([]);

const result = await createSnapshotCatalog({ accountId, snapshot });

expect(insertCatalogSongs).not.toHaveBeenCalled();
expect(updatePlaycountSnapshot).toHaveBeenCalledWith(snapshotId, { catalog: catalogId });
expect(result).toEqual({ catalog, songsAdded: 0 });
});

it("falls back to a default name when none is supplied", async () => {
vi.mocked(selectSongMeasurements).mockResolvedValue([]);

await createSnapshotCatalog({ accountId, snapshot });

expect(insertCatalog).toHaveBeenCalledWith("Valuation Catalog");
});
});
50 changes: 50 additions & 0 deletions lib/catalog/__tests__/validateCreateCatalogBody.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { NextResponse } from "next/server";

import { validateCreateCatalogBody } from "../validateCreateCatalogBody";

const snapshotId = "550e8400-e29b-41d4-a716-446655440000";

describe("validateCreateCatalogBody", () => {
it("accepts a name-only body", () => {
const result = validateCreateCatalogBody({ name: "My Catalog" });
expect(result).toEqual({ name: "My Catalog" });
});

it("accepts a snapshot-only body", () => {
const result = validateCreateCatalogBody({ snapshot: snapshotId });
expect(result).toEqual({ snapshot: snapshotId });
});

it("accepts name and snapshot together", () => {
const result = validateCreateCatalogBody({
name: "Bad Bunny — Catalog",
snapshot: snapshotId,
});
expect(result).toEqual({
name: "Bad Bunny — Catalog",
snapshot: snapshotId,
});
});

it("rejects an empty body (neither name nor snapshot) with 400", async () => {
const result = validateCreateCatalogBody({});
expect(result).toBeInstanceOf(NextResponse);
const res = result as NextResponse;
expect(res.status).toBe(400);
const body = await res.json();
expect(body.status).toBe("error");
});

it("rejects a non-uuid snapshot with 400", async () => {
const result = validateCreateCatalogBody({ snapshot: "not-a-uuid" });
expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
});

it("rejects a null/non-object body with 400", async () => {
const result = validateCreateCatalogBody(null);
expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
});
});
Loading
Loading