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
90 changes: 68 additions & 22 deletions lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { NextRequest, NextResponse } from "next/server";
import { getCatalogMeasurementsHandler } from "../getCatalogMeasurementsHandler";
import { validateGetCatalogMeasurementsQuery } from "../validateGetCatalogMeasurementsQuery";
import { getCatalogEarliestReleaseDate } from "../getCatalogEarliestReleaseDate";
import { resolveCatalogSongsInScope } from "../resolveCatalogSongsInScope";
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";
import { selectAllSongMeasurements } from "@/lib/supabase/song_measurements/selectAllSongMeasurements";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
Expand All @@ -16,19 +16,18 @@ vi.mock("../validateGetCatalogMeasurementsQuery", () => ({
validateGetCatalogMeasurementsQuery: vi.fn(),
}));
vi.mock("../getCatalogEarliestReleaseDate", () => ({ getCatalogEarliestReleaseDate: vi.fn() }));
vi.mock("../resolveCatalogSongsInScope", () => ({ resolveCatalogSongsInScope: 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(),
vi.mock("@/lib/supabase/song_measurements/selectAllSongMeasurements", () => ({
selectAllSongMeasurements: vi.fn(),
}));

const accountId = "550e8400-e29b-41d4-a716-446655440000";
const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f";
const artistAccountId = "b1814076-8e19-4a77-9dea-2ec150e26aaa";

const makeRequest = () =>
new NextRequest(`http://localhost/api/catalogs/measurements?catalogId=${catalogId}`);
Expand All @@ -40,7 +39,14 @@ const okAuth = () =>
authToken: "t",
} as never);

const okQuery = () => vi.mocked(validateGetCatalogMeasurementsQuery).mockReturnValue({ catalogId });
const okQuery = (query: Record<string, string> = { catalogId }) =>
vi.mocked(validateGetCatalogMeasurementsQuery).mockReturnValue(query as never);

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

describe("getCatalogMeasurementsHandler", () => {
beforeEach(() => vi.clearAllMocks());
Expand Down Expand Up @@ -75,21 +81,18 @@ describe("getCatalogMeasurementsHandler", () => {

expect(res.status).toBe(404);
expect(selectAccountCatalog).toHaveBeenCalledWith({ accountId, catalogId });
expect(selectCatalogSongTitles).not.toHaveBeenCalled();
expect(resolveCatalogSongsInScope).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([
okCatalog();
vi.mocked(resolveCatalogSongsInScope).mockResolvedValue([
{ isrc: "ISRC1", title: "Song One" },
{ isrc: "ISRC2", title: "Song Two" },
]);
vi.mocked(selectSongMeasurements).mockResolvedValue([
vi.mocked(selectAllSongMeasurements).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" },
Expand All @@ -102,7 +105,11 @@ describe("getCatalogMeasurementsHandler", () => {
const body = await res.json();

expect(res.status).toBe(200);
expect(selectSongMeasurements).toHaveBeenCalledWith({
expect(resolveCatalogSongsInScope).toHaveBeenCalledWith({
catalogId,
artistAccountId: undefined,
});
expect(selectAllSongMeasurements).toHaveBeenCalledWith({
songs: ["ISRC1", "ISRC2"],
platform: "spotify",
metric: "platform_displayed_play_count",
Expand All @@ -114,21 +121,59 @@ describe("getCatalogMeasurementsHandler", () => {
]);
expect(body.total_streams).toBe(250);
expect(body.catalog_age_years).toBe(10);
// whole-catalog read: the echo field states no artist filter was applied
expect(body.artist_account_id).toBeNull();
// 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("scopes the read to the artist and echoes the applied filter", async () => {
okQuery({ catalogId, artist_account_id: artistAccountId });
okAuth();
okCatalog();
vi.mocked(resolveCatalogSongsInScope).mockResolvedValue([{ isrc: "ISRC1", title: "Song One" }]);
vi.mocked(selectAllSongMeasurements).mockResolvedValue([
{ song: "ISRC1", value: 200, captured_at: "2026-07-01T00:00:00Z" },
] as never);
vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01");

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

expect(res.status).toBe(200);
expect(resolveCatalogSongsInScope).toHaveBeenCalledWith({ catalogId, artistAccountId });
expect(body.measurements).toEqual([
{ isrc: "ISRC1", title: "Song One", playcount: 200, measured_at: "2026-07-01T00:00:00Z" },
]);
expect(body.total_streams).toBe(200);
expect(body.artist_account_id).toBe(artistAccountId);
});

it("returns an empty result with a zero band when the artist has no songs in the catalog", async () => {
okQuery({ catalogId, artist_account_id: artistAccountId });
okAuth();
okCatalog();
vi.mocked(resolveCatalogSongsInScope).mockResolvedValue([]);
vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue(null);

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

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

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([]);
okCatalog();
vi.mocked(resolveCatalogSongsInScope).mockResolvedValue([]);
vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue(null);

const res = await getCatalogMeasurementsHandler(makeRequest());
Expand All @@ -138,6 +183,7 @@ describe("getCatalogMeasurementsHandler", () => {
expect(body.measurements).toEqual([]);
expect(body.total_streams).toBe(0);
expect(body.valuation).toEqual({ low: 0, mid: 0, high: 0 });
expect(body.artist_account_id).toBeNull();
});

it("returns 500 when a dependency throws", async () => {
Expand Down
54 changes: 54 additions & 0 deletions lib/catalog/__tests__/resolveCatalogSongsInScope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { resolveCatalogSongsInScope } from "../resolveCatalogSongsInScope";
import { selectCatalogSongTitles } from "@/lib/supabase/catalog_songs/selectCatalogSongTitles";
import { selectSongArtistIsrcs } from "@/lib/supabase/song_artists/selectSongArtistIsrcs";

vi.mock("@/lib/supabase/catalog_songs/selectCatalogSongTitles", () => ({
selectCatalogSongTitles: vi.fn(),
}));
vi.mock("@/lib/supabase/song_artists/selectSongArtistIsrcs", () => ({
selectSongArtistIsrcs: vi.fn(),
}));

const catalogId = "7d3e5c35-1fac-4b88-aa90-e2e4a52dfe78";
const artistAccountId = "b1814076-8e19-4a77-9dea-2ec150e26aaa";

const catalogSongs = [
{ isrc: "ISRC1", title: "Song One" },
{ isrc: "ISRC2", title: "Song Two" },
{ isrc: "ISRC3", title: "Song Three" },
];

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

it("returns every catalog song when no artist filter is given", async () => {
vi.mocked(selectCatalogSongTitles).mockResolvedValue(catalogSongs);

const result = await resolveCatalogSongsInScope({ catalogId });

expect(result).toEqual(catalogSongs);
expect(selectSongArtistIsrcs).not.toHaveBeenCalled();
});

it("restricts to the catalog ∩ song_artists intersection when filtered", async () => {
vi.mocked(selectCatalogSongTitles).mockResolvedValue(catalogSongs);
// ISRC4 is linked to the artist but not in this catalog — must not leak in
vi.mocked(selectSongArtistIsrcs).mockResolvedValue(["ISRC1", "ISRC3", "ISRC4"]);

const result = await resolveCatalogSongsInScope({ catalogId, artistAccountId });

expect(selectSongArtistIsrcs).toHaveBeenCalledWith(artistAccountId);
expect(result).toEqual([
{ isrc: "ISRC1", title: "Song One" },
{ isrc: "ISRC3", title: "Song Three" },
]);
});

it("returns [] when the artist has no songs in the catalog", async () => {
vi.mocked(selectCatalogSongTitles).mockResolvedValue(catalogSongs);
vi.mocked(selectSongArtistIsrcs).mockResolvedValue(["OTHER1"]);

expect(await resolveCatalogSongsInScope({ catalogId, artistAccountId })).toEqual([]);
});
});
20 changes: 20 additions & 0 deletions lib/catalog/__tests__/validateGetCatalogMeasurementsQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,24 @@ describe("validateGetCatalogMeasurementsQuery", () => {
expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
});

it("accepts an optional artist_account_id uuid", () => {
const artistAccountId = "b1814076-8e19-4a77-9dea-2ec150e26aaa";
const result = validateGetCatalogMeasurementsQuery(
new URLSearchParams({ catalogId, artist_account_id: artistAccountId }),
);

expect(result).toEqual({ catalogId, artist_account_id: artistAccountId });
});

it("returns 400 when artist_account_id is malformed", async () => {
const result = validateGetCatalogMeasurementsQuery(
new URLSearchParams({ catalogId, artist_account_id: "not-a-uuid" }),
);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
const body = await (result as NextResponse).json();
expect(body.status).toBe("error");
});
});
23 changes: 14 additions & 9 deletions lib/catalog/getCatalogMeasurementsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,24 @@ import { validateGetCatalogMeasurementsQuery } from "./validateGetCatalogMeasure
import { latestMeasurementsPerIsrc } from "./latestMeasurementsPerIsrc";
import { computeValuationBand } from "./computeValuationBand";
import { getCatalogEarliestReleaseDate } from "./getCatalogEarliestReleaseDate";
import { resolveCatalogSongsInScope } from "./resolveCatalogSongsInScope";
import { selectAccountCatalog } from "@/lib/supabase/account_catalogs/selectAccountCatalog";
import { selectCatalogSongTitles } from "@/lib/supabase/catalog_songs/selectCatalogSongTitles";
import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements";
import { selectAllSongMeasurements } from "@/lib/supabase/song_measurements/selectAllSongMeasurements";

/**
* GET /api/catalogs/measurements?catalogId=
* GET /api/catalogs/measurements?catalogId=&artist_account_id=
*
* Latest Spotify play count per song (ISRC) in a catalog plus a valuation
* band derived at read time with the same model as the marketing valuation
* card. The account is resolved from credentials (Privy bearer or x-api-key);
* a catalog that doesn't exist or belongs to another account is a 404.
* card. Optionally scoped to one artist's songs (catalog_songs ∩
* song_artists) via artist_account_id; the applied filter is echoed back so
* clients can verify the response scope. The read is exhaustive — no
* 1,000-row cap. The account is resolved from credentials (Privy bearer or
* x-api-key); a catalog that doesn't exist or belongs to another account is
* a 404.
*
* @param request - The request object
* @returns `{ status, measurements, valuation, total_streams, catalog_age_years }`
* @returns `{ status, measurements, valuation, total_streams, artist_account_id, catalog_age_years }`
*/
export async function getCatalogMeasurementsHandler(request: NextRequest): Promise<NextResponse> {
try {
Expand All @@ -34,18 +38,18 @@ export async function getCatalogMeasurementsHandler(request: NextRequest): Promi
return authResult;
}
const { accountId } = authResult;
const { catalogId } = validated;
const { catalogId, artist_account_id: artistAccountId } = validated;

const link = await selectAccountCatalog({ accountId, catalogId });
if (!link) {
return errorResponse("Catalog not found", 404);
}

const songs = await selectCatalogSongTitles(catalogId);
const songs = await resolveCatalogSongsInScope({ catalogId, artistAccountId });

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: Artist-scoped valuation can be skewed because the stream total is filtered to the artist while catalog_age_years still comes from the whole catalog. Consider resolving the earliest release date from the scoped song set (or a scoped album/source lookup) when artist_account_id is provided.

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

<comment>Artist-scoped valuation can be skewed because the stream total is filtered to the artist while `catalog_age_years` still comes from the whole catalog. Consider resolving the earliest release date from the scoped song set (or a scoped album/source lookup) when `artist_account_id` is provided.</comment>

<file context>
@@ -34,18 +38,18 @@ export async function getCatalogMeasurementsHandler(request: NextRequest): Promi
     }
 
-    const songs = await selectCatalogSongTitles(catalogId);
+    const songs = await resolveCatalogSongsInScope({ catalogId, artistAccountId });
     const titles = new Map(songs.map(s => [s.isrc, s.title]));
     const [rows, earliestReleaseDate] = await Promise.all([
</file context>

const titles = new Map(songs.map(s => [s.isrc, s.title]));
const [rows, earliestReleaseDate] = await Promise.all([
songs.length > 0
? selectSongMeasurements({
? selectAllSongMeasurements({
songs: songs.map(s => s.isrc),
platform: "spotify",
metric: "platform_displayed_play_count",
Expand All @@ -64,6 +68,7 @@ export async function getCatalogMeasurementsHandler(request: NextRequest): Promi
measurements,
valuation,
total_streams: totalStreams,
artist_account_id: artistAccountId ?? null,
catalog_age_years: catalogAgeYears,
});
} catch (error) {
Expand Down
29 changes: 29 additions & 0 deletions lib/catalog/resolveCatalogSongsInScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
selectCatalogSongTitles,
type CatalogSongTitle,
} from "@/lib/supabase/catalog_songs/selectCatalogSongTitles";
import { selectSongArtistIsrcs } from "@/lib/supabase/song_artists/selectSongArtistIsrcs";

/**
* Resolve the songs a catalog measurements read covers: the whole catalog,
* or — when an artist filter is applied — only the catalog's songs linked
* to that artist account (catalog_songs ∩ song_artists). Songs the artist
* is linked to outside the catalog never leak in.
*
* @param params.catalogId - The catalog to list songs for
* @param params.artistAccountId - Optional artist account to scope the read to
* @returns ISRC + title pairs in scope, or [] when nothing matches
*/
export async function resolveCatalogSongsInScope({
catalogId,
artistAccountId,
}: {
catalogId: string;
artistAccountId?: string;
}): Promise<CatalogSongTitle[]> {
const songs = await selectCatalogSongTitles(catalogId);
if (!artistAccountId) return songs;

const artistIsrcs = new Set(await selectSongArtistIsrcs(artistAccountId));
return songs.filter(song => artistIsrcs.has(song.isrc));
}
1 change: 1 addition & 0 deletions lib/catalog/validateGetCatalogMeasurementsQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const getCatalogMeasurementsQuerySchema = z.object({
catalogId: z
.string({ message: "catalogId parameter is required" })
.uuid("catalogId must be a valid UUID"),
artist_account_id: z.string().uuid("artist_account_id must be a valid UUID").optional(),
});

export type GetCatalogMeasurementsQuery = z.infer<typeof getCatalogMeasurementsQuerySchema>;
Expand Down
Loading
Loading