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
76 changes: 76 additions & 0 deletions lib/artists/__tests__/enrichArtistSpotifyProfile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { enrichArtistSpotifyProfile } from "../enrichArtistSpotifyProfile";
import generateAccessToken from "@/lib/spotify/generateAccessToken";
import getArtist from "@/lib/spotify/getArtist";
import { enrichSearchedArtistProfile } from "@/lib/valuation/enrichSearchedArtistProfile";

vi.mock("@/lib/spotify/generateAccessToken", () => ({ default: vi.fn() }));
vi.mock("@/lib/spotify/getArtist", () => ({ default: vi.fn() }));
vi.mock("@/lib/valuation/enrichSearchedArtistProfile", () => ({
enrichSearchedArtistProfile: vi.fn(),
}));

const artistId = "b1814076-8e19-4a77-9dea-2ec150e26aaa";
const spotifyArtistId = "4Z8W4fKeB5YxbusRsdQVPb";

const okToken = () =>
vi.mocked(generateAccessToken).mockResolvedValue({
access_token: "tok",
token_type: "Bearer",
expires_in: 3600,
error: null,
});

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

it("fetches the real profile and delegates to enrichSearchedArtistProfile", async () => {
okToken();
const spotifyArtist = {
name: "Radiohead",
followers: { total: 1800000 },
images: [{ url: "https://i.scdn.co/image/x" }],
};
vi.mocked(getArtist).mockResolvedValue({ artist: spotifyArtist as never, error: null });

await enrichArtistSpotifyProfile({ artistId, spotifyArtistId });

expect(getArtist).toHaveBeenCalledWith(spotifyArtistId, "tok");
expect(enrichSearchedArtistProfile).toHaveBeenCalledWith({
artistId,
spotifyArtistId,
spotifyArtist,
});
});

it("skips enrichment when the token cannot be minted", async () => {
vi.mocked(generateAccessToken).mockResolvedValue({
access_token: null,
token_type: null,
expires_in: null,
error: new Error("down"),
});

await enrichArtistSpotifyProfile({ artistId, spotifyArtistId });

expect(getArtist).not.toHaveBeenCalled();
expect(enrichSearchedArtistProfile).not.toHaveBeenCalled();
});

it("skips enrichment when the Spotify profile fetch fails", async () => {
okToken();
vi.mocked(getArtist).mockResolvedValue({ artist: null, error: new Error("404") });

await enrichArtistSpotifyProfile({ artistId, spotifyArtistId });

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

it("never throws (best-effort)", async () => {
vi.mocked(generateAccessToken).mockRejectedValue(new Error("boom"));

await expect(
enrichArtistSpotifyProfile({ artistId, spotifyArtistId }),
).resolves.toBeUndefined();
});
});
38 changes: 38 additions & 0 deletions lib/artists/__tests__/resolveOrCreateArtist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectA
import { insertAccountArtistId } from "@/lib/supabase/account_artist_ids/insertAccountArtistId";
import { selectAccountWithSocials } from "@/lib/supabase/accounts/selectAccountWithSocials";
import { updateArtistSocials } from "@/lib/artist/updateArtistSocials";
import { enrichArtistSpotifyProfile } from "@/lib/artists/enrichArtistSpotifyProfile";

vi.mock("@/lib/artists/createArtistInDb", () => ({ createArtistInDb: vi.fn() }));
vi.mock("@/lib/valuation/findCanonicalArtistBySpotifyId", () => ({
Expand All @@ -21,6 +22,9 @@ vi.mock("@/lib/supabase/accounts/selectAccountWithSocials", () => ({
selectAccountWithSocials: vi.fn(),
}));
vi.mock("@/lib/artist/updateArtistSocials", () => ({ updateArtistSocials: vi.fn() }));
vi.mock("@/lib/artists/enrichArtistSpotifyProfile", () => ({
enrichArtistSpotifyProfile: vi.fn(),
}));

const SPOTIFY_ID = "0xPoVNPnxIIUS1vrxAYV00";
const created = { id: "new-1", account_id: "new-1", name: "Del Water Gap" };
Expand Down Expand Up @@ -51,6 +55,40 @@ describe("resolveOrCreateArtist", () => {
expect(result).toEqual({ artist: created, created: true });
});

// chat#1889 row 16: the create-time attach stores the URL path segment as
// the username ("@artist · 0 followers"). Enrich with the real Spotify
// profile right after the attach so verify-socials shows real metadata.
it("enriches the attached social with the real Spotify profile", async () => {
await resolveOrCreateArtist({
name: "Del Water Gap",
accountId: "acct-1",
spotifyArtistId: SPOTIFY_ID,
});

expect(enrichArtistSpotifyProfile).toHaveBeenCalledWith({
artistId: "new-1",
spotifyArtistId: SPOTIFY_ID,
});
});

it("does not enrich on the plain create path (no spotify id)", async () => {
await resolveOrCreateArtist({ name: "X", accountId: "acct-1" });

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

it("does not enrich when the social attach fails (nothing to enrich)", async () => {
vi.mocked(updateArtistSocials).mockRejectedValue(new Error("nope"));

await resolveOrCreateArtist({
name: "Del Water Gap",
accountId: "acct-1",
spotifyArtistId: SPOTIFY_ID,
});

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

// One canonical artist per Spotify id (chat#1889, decision 2026-07-29):
// when it exists, link it to the account — never mint a second row.
it("links and returns the existing canonical instead of creating", async () => {
Expand Down
34 changes: 34 additions & 0 deletions lib/artists/enrichArtistSpotifyProfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import generateAccessToken from "@/lib/spotify/generateAccessToken";
import getArtist from "@/lib/spotify/getArtist";
import { enrichSearchedArtistProfile } from "@/lib/valuation/enrichSearchedArtistProfile";

/**
* Fetch an artist's real Spotify profile and enrich its attached social +
* avatar with it (chat#1889 row 16). Callers that don't already hold a
* resolved SpotifyArtist (like resolveOrCreateArtist's create path, which
* only has the id) use this; callers that do should call
* enrichSearchedArtistProfile directly and skip the extra fetch.
*
* Best-effort: never throws — enrichment must not fail the artist add. The
* social stays fixable in verify-socials if Spotify is unreachable.
*
* @param params.artistId - The artist account id whose social to enrich
* @param params.spotifyArtistId - The Spotify artist id to fetch
*/
export async function enrichArtistSpotifyProfile(params: {
artistId: string;
spotifyArtistId: string;
}): Promise<void> {
const { artistId, spotifyArtistId } = params;
try {
const token = await generateAccessToken();
if (!token.access_token) return;

const { artist: spotifyArtist } = await getArtist(spotifyArtistId, token.access_token);
if (!spotifyArtist) return;

await enrichSearchedArtistProfile({ artistId, spotifyArtistId, spotifyArtist });
} catch (error) {
console.error("Error enriching artist Spotify profile:", error);
}
}
9 changes: 9 additions & 0 deletions lib/artists/resolveOrCreateArtist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectA
import { insertAccountArtistId } from "@/lib/supabase/account_artist_ids/insertAccountArtistId";
import { selectAccountWithSocials } from "@/lib/supabase/accounts/selectAccountWithSocials";
import { updateArtistSocials } from "@/lib/artist/updateArtistSocials";
import { enrichArtistSpotifyProfile } from "@/lib/artists/enrichArtistSpotifyProfile";

export interface ResolveOrCreateArtistParams {
name: string;
Expand Down Expand Up @@ -59,6 +60,14 @@ export async function resolveOrCreateArtist(
await updateArtistSocials(created.account_id, {
SPOTIFY: `https://open.spotify.com/artist/${spotifyArtistId}`,
});
// The attach stores the URL path segment as the username, which renders
// as "@artist · 0 followers" in verify-socials (chat#1889 row 16).
// Overwrite it with the real Spotify handle/followers/avatar. Inside the
// same try: if the attach failed there is no linked social to enrich.
await enrichArtistSpotifyProfile({
artistId: created.account_id,
spotifyArtistId,
});
} catch {
// Non-fatal by design — see docstring.
}
Expand Down
Loading