Skip to content
Merged
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/measurements/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 { getCatalogMeasurementsHandler } from "@/lib/catalog/getCatalogMeasurementsHandler";

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

/**
* GET handler for retrieving a catalog's latest per-song play counts and the
* derived valuation band.
*
* @param request - The request object containing the catalogId query parameter.
* @returns A NextResponse with measurements, valuation band, and totals.
*/
export async function GET(request: NextRequest) {
return getCatalogMeasurementsHandler(request);
}
54 changes: 54 additions & 0 deletions lib/catalog/__tests__/computeValuationBand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { computeValuationBand } from "../computeValuationBand";

describe("computeValuationBand", () => {
it("computes the band from lifetime streams + catalog age (marketing card model)", () => {
// 100M lifetime Spotify plays, catalog ~10 years old
const v = computeValuationBand({
totalStreams: 100_000_000,
earliestReleaseDate: "2016-06-12",
now: new Date("2026-06-12"),
});

// annual proxy = 100M / 10y = 10M streams/yr
// spotify gross/yr = 10M x $0.0035 = $35,000
// total gross band = spotify x (1.25 / 1.40 / 1.60)
// NLS = gross x 0.85 x 0.75; value = NLS x (10 / 13 / 16)
expect(v.catalogAgeYears).toBe(10);
expect(v.valuation.low).toBeCloseTo(35_000 * 1.25 * 0.85 * 0.75 * 10, 0);
expect(v.valuation.mid).toBeCloseTo(35_000 * 1.4 * 0.85 * 0.75 * 13, 0);
expect(v.valuation.high).toBeCloseTo(35_000 * 1.6 * 0.85 * 0.75 * 16, 0);
});

it("clamps catalog age to at least one year", () => {
const v = computeValuationBand({
totalStreams: 1_000_000,
earliestReleaseDate: "2026-01-01",
now: new Date("2026-06-12"),
});

expect(v.catalogAgeYears).toBe(1);
// age 1y: annual proxy = lifetime
expect(v.valuation.mid).toBeCloseTo(1_000_000 * 0.0035 * 1.4 * 0.85 * 0.75 * 13, 0);
});

it("returns a zero band for zero streams", () => {
const v = computeValuationBand({
totalStreams: 0,
earliestReleaseDate: "2020-01-01",
now: new Date("2026-06-12"),
});

expect(v.valuation).toEqual({ low: 0, mid: 0, high: 0 });
});

it("falls back to the 5y default age when no release date is known", () => {
const v = computeValuationBand({
totalStreams: 50_000_000,
earliestReleaseDate: null,
now: new Date("2026-06-12"),
});

expect(v.catalogAgeYears).toBe(5);
});
});
74 changes: 74 additions & 0 deletions lib/catalog/__tests__/getCatalogEarliestReleaseDate.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 { getCatalogEarliestReleaseDate } from "../getCatalogEarliestReleaseDate";
import { selectPlaycountSnapshots } from "@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots";
import generateAccessToken from "@/lib/spotify/generateAccessToken";
import getAlbums from "@/lib/spotify/getAlbums";

vi.mock("@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots", () => ({
selectPlaycountSnapshots: vi.fn(),
}));
vi.mock("@/lib/spotify/generateAccessToken", () => ({ default: vi.fn() }));
vi.mock("@/lib/spotify/getAlbums", () => ({ default: vi.fn() }));

const snapshot = (albumIds: string[] | null) => ({ id: "snap_1", album_ids: albumIds });

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

it("returns the earliest release date across the source run's albums", async () => {
vi.mocked(selectPlaycountSnapshots).mockResolvedValue([snapshot(["a1", "a2"])] as never);
vi.mocked(generateAccessToken).mockResolvedValue({ access_token: "tok" } as never);
vi.mocked(getAlbums).mockResolvedValue({
albums: [
{ id: "a1", release_date: "2019-03-01" },
{ id: "a2", release_date: "2015-06-12" },
],
error: null,
});

const result = await getCatalogEarliestReleaseDate("cat_1");

expect(selectPlaycountSnapshots).toHaveBeenCalledWith({ catalog: "cat_1" });
expect(getAlbums).toHaveBeenCalledWith({ ids: ["a1", "a2"], accessToken: "tok" });
expect(result).toBe("2015-06-12");
});

it("uses the newest snapshot that has album ids", async () => {
vi.mocked(selectPlaycountSnapshots).mockResolvedValue([
snapshot(null),
snapshot(["a1"]),
] as never);
vi.mocked(generateAccessToken).mockResolvedValue({ access_token: "tok" } as never);
vi.mocked(getAlbums).mockResolvedValue({
albums: [{ id: "a1", release_date: "2021-01-01" }],
error: null,
});

expect(await getCatalogEarliestReleaseDate("cat_1")).toBe("2021-01-01");
});

it("returns null when no snapshot has album ids", async () => {
vi.mocked(selectPlaycountSnapshots).mockResolvedValue([snapshot(null)] as never);

expect(await getCatalogEarliestReleaseDate("cat_1")).toBeNull();
expect(generateAccessToken).not.toHaveBeenCalled();
});

it("returns null when the Spotify token cannot be generated", async () => {
vi.mocked(selectPlaycountSnapshots).mockResolvedValue([snapshot(["a1"])] as never);
vi.mocked(generateAccessToken).mockResolvedValue({
access_token: null,
error: new Error("nope"),
} as never);

expect(await getCatalogEarliestReleaseDate("cat_1")).toBeNull();
});

it("returns null when the albums fetch fails", async () => {
vi.mocked(selectPlaycountSnapshots).mockResolvedValue([snapshot(["a1"])] as never);
vi.mocked(generateAccessToken).mockResolvedValue({ access_token: "tok" } as never);
vi.mocked(getAlbums).mockResolvedValue({ albums: null, error: new Error("boom") });

expect(await getCatalogEarliestReleaseDate("cat_1")).toBeNull();
});
});
154 changes: 154 additions & 0 deletions lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

import { getCatalogMeasurementsHandler } from "../getCatalogMeasurementsHandler";
import { validateGetCatalogMeasurementsQuery } from "../validateGetCatalogMeasurementsQuery";
import { getCatalogEarliestReleaseDate } from "../getCatalogEarliestReleaseDate";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { selectAccountCatalog } from "@/lib/supabase/account_catalogs/selectAccountCatalog";
import { selectCatalogSongTitles } from "@/lib/supabase/catalog_songs/selectCatalogSongTitles";
import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));
vi.mock("../validateGetCatalogMeasurementsQuery", () => ({
validateGetCatalogMeasurementsQuery: vi.fn(),
}));
vi.mock("../getCatalogEarliestReleaseDate", () => ({ getCatalogEarliestReleaseDate: vi.fn() }));
vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() }));
vi.mock("@/lib/supabase/account_catalogs/selectAccountCatalog", () => ({
selectAccountCatalog: vi.fn(),
}));
vi.mock("@/lib/supabase/catalog_songs/selectCatalogSongTitles", () => ({
selectCatalogSongTitles: vi.fn(),
}));
vi.mock("@/lib/supabase/song_measurements/selectSongMeasurements", () => ({
selectSongMeasurements: vi.fn(),
}));

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

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

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

const okQuery = () => vi.mocked(validateGetCatalogMeasurementsQuery).mockReturnValue({ catalogId });

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

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

const res = await getCatalogMeasurementsHandler(makeRequest());

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

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

const res = await getCatalogMeasurementsHandler(makeRequest());

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

it("returns 404 when the catalog does not belong to the caller", async () => {
okQuery();
okAuth();
vi.mocked(selectAccountCatalog).mockResolvedValue(null);

const res = await getCatalogMeasurementsHandler(makeRequest());

expect(res.status).toBe(404);
expect(selectAccountCatalog).toHaveBeenCalledWith({ accountId, catalogId });
expect(selectCatalogSongTitles).not.toHaveBeenCalled();
});

it("returns latest per-ISRC measurements + the derived valuation band", async () => {
okQuery();
okAuth();
vi.mocked(selectAccountCatalog).mockResolvedValue({
account: accountId,
catalog: catalogId,
} as never);
vi.mocked(selectCatalogSongTitles).mockResolvedValue([
{ isrc: "ISRC1", title: "Song One" },
{ isrc: "ISRC2", title: "Song Two" },
]);
vi.mocked(selectSongMeasurements).mockResolvedValue([
// newest-first series; ISRC1 has an older superseded capture
{ song: "ISRC1", value: 200, captured_at: "2026-07-01T00:00:00Z" },
{ song: "ISRC2", value: 50, captured_at: "2026-07-01T00:00:00Z" },
{ song: "ISRC1", value: 100, captured_at: "2026-06-01T00:00:00Z" },
] as never);
// 10 years of catalog age
vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01");

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 test "returns latest per-ISRC measurements + the derived valuation band" freezes earliestReleaseDate to 2016-07-01 but relies on real system time to determine the catalog age — computeValuationBand is called without a now override, so every day new Date() shifts. The expected valuation constants are hardcoded for a 10-year age (2016 → 2026), meaning the test will produce wrong assertions starting ~mid-2027 when the age naturally ticks to 11 years.

computeValuationBand explicitly exposes a now?: Date parameter documented as "Clock override for tests" — the test should freeze time with vi.setSystemTime so the valuation output is deterministic regardless of when the suite runs.

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

<comment>The test "returns latest per-ISRC measurements + the derived valuation band" freezes `earliestReleaseDate` to `2016-07-01` but relies on real system time to determine the catalog age — `computeValuationBand` is called without a `now` override, so every day `new Date()` shifts. The expected valuation constants are hardcoded for a 10-year age (2016 → 2026), meaning the test will produce wrong assertions starting ~mid-2027 when the age naturally ticks to 11 years.

`computeValuationBand` explicitly exposes a `now?: Date` parameter documented as "Clock override for tests" — the test should freeze time with `vi.setSystemTime` so the valuation output is deterministic regardless of when the suite runs.</comment>

<file context>
@@ -0,0 +1,154 @@
+      { song: "ISRC1", value: 100, captured_at: "2026-06-01T00:00:00Z" },
+    ] as never);
+    // 10 years of catalog age
+    vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01");
+
+    const res = await getCatalogMeasurementsHandler(makeRequest());
</file context>


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

expect(res.status).toBe(200);
expect(selectSongMeasurements).toHaveBeenCalledWith({
songs: ["ISRC1", "ISRC2"],
platform: "spotify",
metric: "platform_displayed_play_count",
});
expect(body.status).toBe("success");
expect(body.measurements).toEqual([
{ isrc: "ISRC1", title: "Song One", playcount: 200, measured_at: "2026-07-01T00:00:00Z" },
{ isrc: "ISRC2", title: "Song Two", playcount: 50, measured_at: "2026-07-01T00:00:00Z" },
]);
expect(body.total_streams).toBe(250);
expect(body.catalog_age_years).toBe(10);
// 250 streams / 10y * $0.0035 * gross-up * 0.6375 net * multiple
expect(body.valuation.low).toBeCloseTo(25 * 0.0035 * 1.25 * 0.6375 * 10, 5);
expect(body.valuation.mid).toBeCloseTo(25 * 0.0035 * 1.4 * 0.6375 * 13, 5);
expect(body.valuation.high).toBeCloseTo(25 * 0.0035 * 1.6 * 0.6375 * 16, 5);
});

it("returns an empty result with a zero band for a catalog with no measurements", async () => {
okQuery();
okAuth();
vi.mocked(selectAccountCatalog).mockResolvedValue({
account: accountId,
catalog: catalogId,
} as never);
vi.mocked(selectCatalogSongTitles).mockResolvedValue([]);
vi.mocked(selectSongMeasurements).mockResolvedValue([]);
vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue(null);

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

expect(res.status).toBe(200);
expect(body.measurements).toEqual([]);
expect(body.total_streams).toBe(0);
expect(body.valuation).toEqual({ low: 0, mid: 0, high: 0 });
});

it("returns 500 when a dependency throws", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
okQuery();
okAuth();
vi.mocked(selectAccountCatalog).mockRejectedValue(new Error("boom"));

const res = await getCatalogMeasurementsHandler(makeRequest());

expect(res.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 verifies status but not the response body content. Per team convention, 500 responses should return a hardcoded "Internal server error" without leaking the exception message. The handler is implemented correctly (errorResponse("Internal server error", 500)), so the risk is in regression — a future change to the catch block could accidentally include the error message without a body assertion catching it. Add a check like const body = await res.json(); expect(body.error).toBe("Internal server error"); to lock in the expected safe envelope.

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

<comment>The 500-error test verifies status but not the response body content. Per team convention, 500 responses should return a hardcoded "Internal server error" without leaking the exception message. The handler is implemented correctly (errorResponse("Internal server error", 500)), so the risk is in regression — a future change to the catch block could accidentally include the error message without a body assertion catching it. Add a check like `const body = await res.json(); expect(body.error).toBe("Internal server error");` to lock in the expected safe envelope.</comment>

<file context>
@@ -0,0 +1,154 @@
+
+    const res = await getCatalogMeasurementsHandler(makeRequest());
+
+    expect(res.status).toBe(500);
+    consoleError.mockRestore();
+  });
</file context>

consoleError.mockRestore();
});
});
59 changes: 59 additions & 0 deletions lib/catalog/__tests__/latestMeasurementsPerIsrc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { latestMeasurementsPerIsrc } from "../latestMeasurementsPerIsrc";
import type { Tables } from "@/types/database.types";

const row = (song: string, value: number, captured_at: string): Tables<"song_measurements"> =>
({
id: `${song}-${captured_at}`,
song,
value,
captured_at,
created_at: captured_at,
data_source: "spotify_web",
metric: "platform_displayed_play_count",
platform: "spotify",
raw_ref: null,
snapshot: null,
}) as Tables<"song_measurements">;

describe("latestMeasurementsPerIsrc", () => {
it("keeps only the newest capture per ISRC and sums the total", () => {
// rows arrive newest-first (selectSongMeasurements orders captured_at desc)
const rows = [
row("ISRC1", 200, "2026-07-01T00:00:00Z"),
row("ISRC2", 50, "2026-07-01T00:00:00Z"),
row("ISRC1", 100, "2026-06-01T00:00:00Z"),
];
const titles = new Map([
["ISRC1", "Song One"],
["ISRC2", null],
]);

const result = latestMeasurementsPerIsrc(rows, titles);

expect(result.totalStreams).toBe(250);
expect(result.measurements).toEqual([
{ isrc: "ISRC1", title: "Song One", playcount: 200, measured_at: "2026-07-01T00:00:00Z" },
{ isrc: "ISRC2", title: null, playcount: 50, measured_at: "2026-07-01T00:00:00Z" },
]);
});

it("sorts measurements by playcount descending", () => {
const rows = [
row("ISRC1", 10, "2026-07-01T00:00:00Z"),
row("ISRC2", 999, "2026-07-01T00:00:00Z"),
];

const result = latestMeasurementsPerIsrc(rows, new Map());

expect(result.measurements.map(m => m.isrc)).toEqual(["ISRC2", "ISRC1"]);
expect(result.measurements[0].title).toBeNull();
});

it("returns an empty result for no rows", () => {
expect(latestMeasurementsPerIsrc([], new Map())).toEqual({
measurements: [],
totalStreams: 0,
});
});
});
Loading
Loading