-
Notifications
You must be signed in to change notification settings - Fork 9
feat(catalogs): GET /api/catalogs/measurements — per-ISRC latest playcounts + derived valuation band (chat#1850) #757
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| } |
| 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); | ||
| }); | ||
| }); |
| 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(); | ||
| }); | ||
| }); |
| 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"); | ||
|
|
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||
| consoleError.mockRestore(); | ||
| }); | ||
| }); | ||
| 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, | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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
earliestReleaseDateto2016-07-01but relies on real system time to determine the catalog age —computeValuationBandis called without anowoverride, so every daynew 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.computeValuationBandexplicitly exposes anow?: Dateparameter documented as "Clock override for tests" — the test should freeze time withvi.setSystemTimeso the valuation output is deterministic regardless of when the suite runs.Prompt for AI agents