Skip to content
Open
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
32 changes: 32 additions & 0 deletions app/api/catalogs/[catalogId]/valuations/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getCatalogValuationsHandler } from "@/lib/catalog/getCatalogValuationsHandler";

/**
* OPTIONS /api/catalogs/{catalogId}/valuations — CORS preflight.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* GET /api/catalogs/{catalogId}/valuations — the catalog's persisted
* valuation history, latest-first (limit=1 is the current value).
*
* @param request - The request object.
* @param options - Route options containing params.
* @param options.params - Route params containing the catalogId.
* @returns A NextResponse with the valuation series.
*/
export async function GET(
request: NextRequest,
options: { params: Promise<{ catalogId: string }> },
) {
const { catalogId } = await options.params;
return getCatalogValuationsHandler(request, catalogId);
}
40 changes: 40 additions & 0 deletions lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getCatalogEarliestReleaseDate } from "../getCatalogEarliestReleaseDate"
import { selectAccountCatalog } from "@/lib/supabase/account_catalogs/selectAccountCatalog";
import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate";
import { selectCatalogMeasurementsPage } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsPage";
import { persistCatalogValuation } from "../persistCatalogValuation";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
Expand All @@ -24,6 +25,7 @@ vi.mock("@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate", (
vi.mock("@/lib/supabase/song_measurements/selectCatalogMeasurementsPage", () => ({
selectCatalogMeasurementsPage: vi.fn(),
}));
vi.mock("../persistCatalogValuation", () => ({ persistCatalogValuation: vi.fn() }));

const accountId = "550e8400-e29b-41d4-a716-446655440000";
const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f";
Expand Down Expand Up @@ -122,6 +124,44 @@ describe("getCatalogMeasurementsHandler", () => {
expect(body.valuation.high).toBeCloseTo(25 * 0.0035 * 1.6 * 0.6375 * 16, 5);
});

it("persists the whole-catalog band as a daily-deduped history row (chat#1889 row 15)", async () => {
okQuery();
okCatalog();
vi.mocked(selectCatalogMeasurementsAggregate).mockResolvedValue({
measuredSongCount: 120,
totalStreams: 250,
});
vi.mocked(selectCatalogMeasurementsPage).mockResolvedValue(pageRows);
vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01");

const res = await getCatalogMeasurementsHandler(makeRequest(), catalogId);
const body = await res.json();

expect(res.status).toBe(200);
expect(persistCatalogValuation).toHaveBeenCalledWith({
catalogId,
valuation: body.valuation,
measuredSongCount: 120,
totalStreams: 250,
dedupeDaily: true,
});
});

it("does not persist a history row for an artist-scoped read (partial-catalog band)", async () => {
okQuery({ catalogId, artist_account_id: artistAccountId, page: 1, limit: 50 });
okCatalog();
vi.mocked(selectCatalogMeasurementsAggregate).mockResolvedValue({
measuredSongCount: 11,
totalStreams: 200,
});
vi.mocked(selectCatalogMeasurementsPage).mockResolvedValue([pageRows[0]]);
vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01");

await getCatalogMeasurementsHandler(makeRequest(), catalogId);

expect(persistCatalogValuation).not.toHaveBeenCalled();
});

it("scopes the read to the artist and echoes the applied filter", async () => {
okQuery({ catalogId, artist_account_id: artistAccountId, page: 2, limit: 10 });
okCatalog();
Expand Down
115 changes: 115 additions & 0 deletions lib/catalog/__tests__/getCatalogValuationsHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { getCatalogValuationsHandler } from "../getCatalogValuationsHandler";
import { validateGetCatalogValuationsQuery } from "../validateGetCatalogValuationsQuery";
import { selectAccountCatalog } from "@/lib/supabase/account_catalogs/selectAccountCatalog";
import { selectCatalogValuations } from "@/lib/supabase/catalog_valuations/selectCatalogValuations";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));
vi.mock("../validateGetCatalogValuationsQuery", () => ({
validateGetCatalogValuationsQuery: vi.fn(),
}));
vi.mock("@/lib/supabase/account_catalogs/selectAccountCatalog", () => ({
selectAccountCatalog: vi.fn(),
}));
vi.mock("@/lib/supabase/catalog_valuations/selectCatalogValuations", () => ({
selectCatalogValuations: vi.fn(),
}));

const accountId = "550e8400-e29b-41d4-a716-446655440000";
const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f";

const makeRequest = () => new NextRequest(`http://localhost/api/catalogs/${catalogId}/valuations`);

const okQuery = () =>
vi
.mocked(validateGetCatalogValuationsQuery)
.mockResolvedValue({ accountId, catalogId, limit: 30 });

const okCatalog = () =>
vi
.mocked(selectAccountCatalog)
.mockResolvedValue({ account: accountId, catalog: catalogId } as never);

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

it("short-circuits with the validator error (auth + params live in the validator)", async () => {
const denied = NextResponse.json({ status: "error" }, { status: 401 });
vi.mocked(validateGetCatalogValuationsQuery).mockResolvedValue(denied);

const response = await getCatalogValuationsHandler(makeRequest(), catalogId);

expect(response).toBe(denied);
expect(selectAccountCatalog).not.toHaveBeenCalled();
});

it("returns 404 when the catalog is not owned by the caller (or missing)", async () => {
okQuery();
vi.mocked(selectAccountCatalog).mockResolvedValue(null);

const response = await getCatalogValuationsHandler(makeRequest(), catalogId);

expect(response.status).toBe(404);
expect(selectCatalogValuations).not.toHaveBeenCalled();
});

it("returns the valuation series shaped to the documented contract", async () => {
okQuery();
okCatalog();
const rows = [
{
id: "v2",
catalog_id: catalogId,
low: "1000",
mid: "2000",
high: "3000",
measured_song_count: 12,
total_streams: 456789,
measured_at: "2026-07-29T00:00:00Z",
created_at: "2026-07-29T00:00:00Z",
},
];
vi.mocked(selectCatalogValuations).mockResolvedValue(rows as never);

const response = await getCatalogValuationsHandler(makeRequest(), catalogId);
const body = await response.json();

expect(response.status).toBe(200);
expect(selectCatalogValuations).toHaveBeenCalledWith({ catalogId, limit: 30 });
expect(body.valuations).toEqual([
{
low: 1000,
mid: 2000,
high: 3000,
measured_song_count: 12,
total_streams: 456789,
measured_at: "2026-07-29T00:00:00Z",
},
]);
});

it("returns an empty series when the catalog has no valuations yet", async () => {
okQuery();
okCatalog();
vi.mocked(selectCatalogValuations).mockResolvedValue([]);

const response = await getCatalogValuationsHandler(makeRequest(), catalogId);
const body = await response.json();

expect(response.status).toBe(200);
expect(body.valuations).toEqual([]);
});

it("returns 500 when the select fails", async () => {
okQuery();
okCatalog();
vi.mocked(selectCatalogValuations).mockResolvedValue(null);

const response = await getCatalogValuationsHandler(makeRequest(), catalogId);

expect(response.status).toBe(500);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The 500-error test only checks response.status === 500 and never verifies the response body. Per the team's established convention, tests for 500 responses should assert that raw exception text does not leak into the body (e.g., assert body.error === "Internal server error" or that stack traces/DB errors are absent). Without this check, a future change that accidentally returns a dynamic error message would not be caught.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/__tests__/getCatalogValuationsHandler.test.ts, line 113:

<comment>The 500-error test only checks `response.status === 500` and never verifies the response body. Per the team's established convention, tests for 500 responses should assert that raw exception text does not leak into the body (e.g., assert `body.error === "Internal server error"` or that stack traces/DB errors are absent). Without this check, a future change that accidentally returns a dynamic error message would not be caught.</comment>

<file context>
@@ -0,0 +1,115 @@
+
+    const response = await getCatalogValuationsHandler(makeRequest(), catalogId);
+
+    expect(response.status).toBe(500);
+  });
+});
</file context>

});
});
94 changes: 94 additions & 0 deletions lib/catalog/__tests__/persistCatalogValuation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { persistCatalogValuation } from "../persistCatalogValuation";
import { insertCatalogValuation } from "@/lib/supabase/catalog_valuations/insertCatalogValuation";
import { selectCatalogValuations } from "@/lib/supabase/catalog_valuations/selectCatalogValuations";

vi.mock("@/lib/supabase/catalog_valuations/insertCatalogValuation", () => ({
insertCatalogValuation: vi.fn(),
}));
vi.mock("@/lib/supabase/catalog_valuations/selectCatalogValuations", () => ({
selectCatalogValuations: vi.fn(),
}));

const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f";
const input = {
catalogId,
valuation: { low: 1000, mid: 2000, high: 3000 },
measuredSongCount: 12,
totalStreams: 456789,
};

describe("persistCatalogValuation", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});

it("inserts a snake_case row from the camelCase inputs", async () => {
vi.mocked(insertCatalogValuation).mockResolvedValue({ id: "v1" } as never);

await persistCatalogValuation(input);

expect(insertCatalogValuation).toHaveBeenCalledWith({
catalog_id: catalogId,
low: 1000,
mid: 2000,
high: 3000,
measured_song_count: 12,
total_streams: 456789,
});
expect(selectCatalogValuations).not.toHaveBeenCalled();
});

it("never throws when the insert fails (best-effort)", async () => {
vi.mocked(insertCatalogValuation).mockRejectedValue(new Error("down"));

await expect(persistCatalogValuation(input)).resolves.toBeUndefined();
});

describe("dedupeDaily", () => {
it("skips the insert when a row already exists for today (UTC)", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-29T18:00:00Z"));
vi.mocked(selectCatalogValuations).mockResolvedValue([
{ measured_at: "2026-07-29T02:00:00Z" } as never,
]);

await persistCatalogValuation({ ...input, dedupeDaily: true });

expect(selectCatalogValuations).toHaveBeenCalledWith({ catalogId, limit: 1 });
expect(insertCatalogValuation).not.toHaveBeenCalled();
});

it("inserts when the newest row is from an earlier day", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-29T18:00:00Z"));
vi.mocked(selectCatalogValuations).mockResolvedValue([
{ measured_at: "2026-07-28T23:59:00Z" } as never,
]);
vi.mocked(insertCatalogValuation).mockResolvedValue({ id: "v2" } as never);

await persistCatalogValuation({ ...input, dedupeDaily: true });

expect(insertCatalogValuation).toHaveBeenCalledTimes(1);
});

it("inserts when the catalog has no valuations yet", async () => {
vi.mocked(selectCatalogValuations).mockResolvedValue([]);
vi.mocked(insertCatalogValuation).mockResolvedValue({ id: "v1" } as never);

await persistCatalogValuation({ ...input, dedupeDaily: true });

expect(insertCatalogValuation).toHaveBeenCalledTimes(1);
});

it("still inserts when the dedupe lookup errors (null)", async () => {
vi.mocked(selectCatalogValuations).mockResolvedValue(null);
vi.mocked(insertCatalogValuation).mockResolvedValue({ id: "v1" } as never);

await persistCatalogValuation({ ...input, dedupeDaily: true });

expect(insertCatalogValuation).toHaveBeenCalledTimes(1);
});
});
});
74 changes: 74 additions & 0 deletions lib/catalog/__tests__/validateGetCatalogValuationsQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateGetCatalogValuationsQuery } from "../validateGetCatalogValuationsQuery";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));
vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() }));

const accountId = "550e8400-e29b-41d4-a716-446655440000";
const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f";

const makeRequest = (query = "") =>
new NextRequest(`http://localhost/api/catalogs/${catalogId}/valuations${query}`);

const okAuth = () =>
vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null } as never);

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

it("short-circuits with the auth error before touching params", async () => {
const denied = NextResponse.json({ status: "error" }, { status: 401 });
vi.mocked(validateAuthContext).mockResolvedValue(denied as never);

const result = await validateGetCatalogValuationsQuery(makeRequest(), catalogId);

expect(result).toBe(denied);
});

it("returns the accountId, catalogId and default limit of 30", async () => {
okAuth();

const result = await validateGetCatalogValuationsQuery(makeRequest(), catalogId);

expect(result).toEqual({ accountId, catalogId, limit: 30 });
});

it("accepts an explicit limit", async () => {
okAuth();

const result = await validateGetCatalogValuationsQuery(makeRequest("?limit=1"), catalogId);

expect(result).toEqual({ accountId, catalogId, limit: 1 });
});

it("rejects a non-UUID catalogId with 400", async () => {
okAuth();

const result = await validateGetCatalogValuationsQuery(makeRequest(), "not-a-uuid");

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
});

it("rejects limit above 100 with 400", async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Missing test coverage for the lower limit boundary. The schema rejects limit=0 and limit=-1 with 400, but only the upper boundary (limit=101) and non-numeric input are tested. Add a test for limit=0 or limit=-1 to match the boundary coverage on the lower end.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/__tests__/validateGetCatalogValuationsQuery.test.ts, line 57:

<comment>Missing test coverage for the lower limit boundary. The schema rejects limit=0 and limit=-1 with 400, but only the upper boundary (limit=101) and non-numeric input are tested. Add a test for limit=0 or limit=-1 to match the boundary coverage on the lower end.</comment>

<file context>
@@ -0,0 +1,74 @@
+    expect((result as NextResponse).status).toBe(400);
+  });
+
+  it("rejects limit above 100 with 400", async () => {
+    okAuth();
+
</file context>

okAuth();

const result = await validateGetCatalogValuationsQuery(makeRequest("?limit=101"), catalogId);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
});

it("rejects a non-numeric limit with 400", async () => {
okAuth();

const result = await validateGetCatalogValuationsQuery(makeRequest("?limit=abc"), catalogId);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
});
});
Loading
Loading