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
80 changes: 80 additions & 0 deletions lib/catalog/__tests__/buildReleaseRollups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import { buildReleaseRollups } from "../buildReleaseRollups";

describe("buildReleaseRollups", () => {
it("groups songs by album, joins play counts by ISRC, and sorts by streams desc", () => {
const songs = [
{ album: "Album A", isrc: "US1", artists: [{ name: "Nova" }] },
{ album: "Album A", isrc: "US2", artists: [{ name: "Nova" }, { name: "Guest" }] },
{ album: "Album B", isrc: "US3", artists: [{ name: "Nova" }] },
];
const measurements = [
{ isrc: "US1", playcount: 100 },
{ isrc: "US2", playcount: 200 },
{ isrc: "US3", playcount: 5000 },
];

const rollups = buildReleaseRollups(songs, measurements);

expect(rollups).toEqual([
{
album: "Album B",
artistNames: ["Nova"],
trackCount: 1,
measuredTrackCount: 1,
streams: 5000,
},
{
album: "Album A",
artistNames: ["Nova", "Guest"],
trackCount: 2,
measuredTrackCount: 2,
streams: 300,
},
]);
});

it("proportional shares of the band sum back to the headline (no divergence)", () => {
const songs = [
{ album: "A", isrc: "US1", artists: [] },
{ album: "B", isrc: "US2", artists: [] },
];
const measurements = [
{ isrc: "US1", playcount: 300 },
{ isrc: "US2", playcount: 700 },
];
const bandMid = 1_000_000;

const rollups = buildReleaseRollups(songs, measurements);
const total = rollups.reduce((s, r) => s + r.streams, 0);
const perAlbum = rollups.map(r => (bandMid * r.streams) / total);

expect(total).toBe(1000);
expect(perAlbum.reduce((s, v) => s + v, 0)).toBeCloseTo(bandMid, 5);
});

it("is null-safe: null album/name/artist entries never throw", () => {
const songs = [
{ album: null, isrc: "US1", artists: [null, { name: null }] },
{ album: " ", isrc: "US2", artists: null },
] as never;

const rollups = buildReleaseRollups(songs, []);

expect(rollups).toHaveLength(1);
expect(rollups[0].album).toBeNull();
expect(rollups[0].trackCount).toBe(2);
expect(rollups[0].measuredTrackCount).toBe(0);
expect(rollups[0].artistNames).toEqual([]);
});

it("counts unmeasured tracks toward trackCount but not streams", () => {
const songs = [
{ album: "A", isrc: "US1", artists: [] },
{ album: "A", isrc: "US2", artists: [] },
];
const rollups = buildReleaseRollups(songs, [{ isrc: "US1", playcount: 42 }]);

expect(rollups[0]).toMatchObject({ trackCount: 2, measuredTrackCount: 1, streams: 42 });
});
});
Binary file added lib/catalog/buildReleaseRollups.ts
Binary file not shown.
21 changes: 21 additions & 0 deletions lib/emails/valuationReport/__tests__/buildAlbumArtMap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { buildAlbumArtMap } from "../buildAlbumArtMap";

describe("buildAlbumArtMap", () => {
it("maps lowercased album names to the smallest cover-art url", () => {
const albums = [
{
id: "1",
name: "Album A",
images: [
{ url: "big.jpg", height: 640, width: 640 },
{ url: "small.jpg", height: 64, width: 64 },
],
},
{ id: "2", name: "No Art" },
];
const map = buildAlbumArtMap(albums);
expect(map.get("album a")).toBe("small.jpg");
expect(map.has("no art")).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { buildValuationReleaseRows } from "../buildValuationReleaseRows";

describe("buildValuationReleaseRows", () => {
const rollups = [
{ album: "Album B", artistNames: ["Nova"], trackCount: 1, measuredTrackCount: 1, streams: 700 },
{ album: "Album A", artistNames: ["Nova"], trackCount: 1, measuredTrackCount: 1, streams: 300 },
];

it("computes proportional value that sums to the headline band", () => {
const rows = buildValuationReleaseRows({
rollups,
totalStreams: 1000,
bandMid: 1_000_000,
artByAlbum: new Map([["album b", "b.jpg"]]),
});
expect(rows.map(r => r.value)).toEqual([700_000, 300_000]);
expect(rows.reduce((s, r) => s + r.value, 0)).toBe(1_000_000);
expect(rows[0].artUrl).toBe("b.jpg");
expect(rows[1].artUrl).toBeNull();
});

it("is safe when totalStreams is zero (no divide-by-zero)", () => {
const rows = buildValuationReleaseRows({
rollups,
totalStreams: 0,
bandMid: 1_000_000,
artByAlbum: new Map(),
});
expect(rows.every(r => r.value === 0)).toBe(true);
});

it("leaves art null for untitled releases", () => {
const rows = buildValuationReleaseRows({
rollups: [{ album: null, artistNames: [], trackCount: 1, measuredTrackCount: 0, streams: 0 }],
totalStreams: 1000,
bandMid: 1000,
artByAlbum: new Map([["", "x.jpg"]]),
});
expect(rows[0].artUrl).toBeNull();
});
});
24 changes: 24 additions & 0 deletions lib/emails/valuationReport/__tests__/formatCompactNumber.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { formatCompactNumber } from "../formatCompactNumber";

describe("formatCompactNumber", () => {
it("passes small numbers through, rounded to integers", () => {
expect(formatCompactNumber(0)).toBe("0");
expect(formatCompactNumber(678)).toBe("678");
expect(formatCompactNumber(999.6)).toBe("1K");
});

it("formats thousands with one decimal, trailing .0 stripped", () => {
expect(formatCompactNumber(12_345)).toBe("12.3K");
expect(formatCompactNumber(10_000)).toBe("10K");
});

it("formats millions", () => {
expect(formatCompactNumber(1_200_000)).toBe("1.2M");
expect(formatCompactNumber(348_000_000)).toBe("348M");
});

it("formats billions (large catalog lifetime streams)", () => {
expect(formatCompactNumber(1_400_000_000)).toBe("1.4B");
});
});
109 changes: 109 additions & 0 deletions lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import { renderValuationReportHtml } from "../renderValuationReportHtml";

const baseParams = {
catalogName: "Epitaph 10-Album Catalog",
deepLinkUrl: "https://chat.recoupable.dev/catalogs/cat_1",
albumCount: 10,
artist: {
name: "Nova",
imageUrl: "https://i.scdn.co/image/artist.jpg",
followers: 1_240_000,
},
valuation: { low: 7_200, mid: 10_400, high: 14_100 },
totalStreams: 3_480_000,
measuredSongCount: 112,
releaseCount: 3,
catalogAgeYears: 5,
releases: [
{
album: "Album B",
artistNames: ["Nova"],
streams: 2_500_000,
value: 7_471,
artUrl: "https://i.scdn.co/image/albumB.jpg",
},
{
album: "Album A",
artistNames: ["Nova", "Guest"],
streams: 980_000,
value: 2_929,
artUrl: null,
},
],
};

describe("renderValuationReportHtml", () => {
it("uses the fixed subject line", () => {
expect(renderValuationReportHtml(baseParams).subject).toBe("Your catalog valuation is ready");
});

it("renders catalog name, headline value, range, streams, and counts", () => {
const { html } = renderValuationReportHtml(baseParams);
expect(html).toContain("Epitaph 10-Album Catalog");
expect(html).toContain("Estimated catalog value");
expect(html).toContain("$10.4K");
expect(html).toContain("Range $7.2K to $14.1K");
expect(html).toContain("3.5M"); // lifetime streams
expect(html).toContain("112"); // tracks measured
expect(html).toContain("Releases");
expect(html).toContain("5 years"); // catalog age
});

it("renders the artist header with image and follower count", () => {
const { html } = renderValuationReportHtml(baseParams);
expect(html).toContain('src="https://i.scdn.co/image/artist.jpg"');
expect(html).toContain(">Nova<");
expect(html).toContain("1.2M followers");
});

it("renders a release table row with art, name, streams, and proportional value", () => {
const { html } = renderValuationReportHtml(baseParams);
expect(html).toContain('src="https://i.scdn.co/image/albumB.jpg"');
expect(html).toContain("Album B");
expect(html).toContain("Album A");
expect(html).toContain("Nova, Guest"); // multi-artist row
expect(html).toContain("2.5M"); // release streams
expect(html).toContain("$7.5K"); // release value (7471 -> 7.5K)
expect(html).toContain("$2.9K"); // release value (2929 -> 2.9K)
});

it("shows the directional-model disclaimer when valued", () => {
const { html } = renderValuationReportHtml(baseParams);
expect(html).toContain("Directional model, not an appraisal");
expect(html).toContain("10 to 16x");
});

it("links the CTA to the deep link with the full-report copy", () => {
const { html } = renderValuationReportHtml(baseParams);
expect(html).toContain('href="https://chat.recoupable.dev/catalogs/cat_1"');
expect(html).toContain("Get the full report with Recoup");
});

it("escapes HTML in the catalog name", () => {
const { html } = renderValuationReportHtml({
...baseParams,
catalogName: '<img src="x">Catalog',
});
expect(html).not.toContain('<img src="x">Catalog');
expect(html).toContain("&lt;img src=&quot;x&quot;&gt;Catalog");
});

it("degrades to a link-only email when no measurements are available", () => {
const { html } = renderValuationReportHtml({
catalogName: null,
deepLinkUrl: "https://chat.recoupable.dev",
albumCount: 4,
});
expect(html).not.toContain("Estimated catalog value");
expect(html).not.toContain("Directional model");
expect(html).toContain("Your catalog");
expect(html).toContain('href="https://chat.recoupable.dev"');
});

it("contains no em or en dashes in outward-facing copy", () => {
const { html, subject } = renderValuationReportHtml(baseParams);
expect(subject).not.toMatch(/[–—]/);
expect(html).not.toMatch(/[–—]/);
});
});
Loading
Loading