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
81 changes: 68 additions & 13 deletions lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { NextRequest, NextResponse } from "next/server";

import { postArtistSocialsScrapeHandler } from "../postArtistSocialsScrapeHandler";
import { scrapeProfileUrlBatch } from "@/lib/apify/scrapeProfileUrlBatch";
import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess";
import { ensureSocialScrapeCredits } from "@/lib/socials/ensureSocialScrapeCredits";
import { deductSocialScrapeCredits } from "@/lib/socials/deductSocialScrapeCredits";

vi.mock("@/lib/apify/scrapeProfileUrlBatch", () => ({ scrapeProfileUrlBatch: vi.fn() }));
vi.mock("@/lib/supabase/account_socials/selectAccountSocials", () => ({
selectAccountSocials: vi.fn(),
}));
vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() }));
vi.mock("@/lib/artists/checkAccountArtistAccess", () => ({ checkAccountArtistAccess: vi.fn() }));
vi.mock("@/lib/socials/ensureSocialScrapeCredits", () => ({ ensureSocialScrapeCredits: vi.fn() }));
vi.mock("@/lib/socials/deductSocialScrapeCredits", () => ({ deductSocialScrapeCredits: vi.fn() }));
vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) }));

const ARTIST_ID = "660e8400-e29b-41d4-a716-446655440000";
const ACCOUNT_ID = "770e8400-e29b-41d4-a716-446655440000";
const authResult = { accountId: ACCOUNT_ID, authToken: "t", orgId: null };

const TWO_SOCIALS = [
{ social: { profile_url: "https://x.com/a", username: "a" } },
{ social: { profile_url: "https://youtube.com/@b", username: "b" } },
];

const makeRequest = (body: unknown) =>
new NextRequest("http://localhost/api/artist/socials/scrape", {
Expand All @@ -22,11 +37,13 @@ const makeRequest = (body: unknown) =>
describe("postArtistSocialsScrapeHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(selectAccountSocials).mockResolvedValue([
{ social: { profile_url: "https://x.com/a", username: "a" } } as never,
]);
vi.mocked(validateAuthContext).mockResolvedValue(authResult);
vi.mocked(checkAccountArtistAccess).mockResolvedValue(true);
vi.mocked(ensureSocialScrapeCredits).mockResolvedValue(null);
vi.mocked(selectAccountSocials).mockResolvedValue(TWO_SOCIALS as never);
vi.mocked(scrapeProfileUrlBatch).mockResolvedValue([
{ runId: "r1", datasetId: "d1", error: null },
{ runId: "r2", datasetId: "d2", error: null },
]);
});

Expand All @@ -36,28 +53,66 @@ describe("postArtistSocialsScrapeHandler", () => {
expect(scrapeProfileUrlBatch).not.toHaveBeenCalled();
});

it("scrapes without a posts depth by default (legacy behavior)", async () => {
it("propagates the auth error (endpoint is no longer open)", async () => {
const err = NextResponse.json({}, { status: 401 });
vi.mocked(validateAuthContext).mockResolvedValue(err);
expect(
await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID })),
).toBe(err);
expect(selectAccountSocials).not.toHaveBeenCalled();
expect(scrapeProfileUrlBatch).not.toHaveBeenCalled();
});

it("returns 403 when the caller has no access to the artist", async () => {
vi.mocked(checkAccountArtistAccess).mockResolvedValue(false);
const res = await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID }));
expect(res.status).toBe(403);
expect(checkAccountArtistAccess).toHaveBeenCalledWith(ACCOUNT_ID, ARTIST_ID);
expect(scrapeProfileUrlBatch).not.toHaveBeenCalled();
});

it("gates on (5 + posts) × profiles credits and short-circuits with the 402", async () => {
const short = NextResponse.json({}, { status: 402 });
vi.mocked(ensureSocialScrapeCredits).mockResolvedValue(short);
expect(
await postArtistSocialsScrapeHandler(
makeRequest({ artist_account_id: ARTIST_ID, posts: 20 }),
),
).toBe(short);
expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 50);
expect(scrapeProfileUrlBatch).not.toHaveBeenCalled();
});

it("scrapes without a posts depth by default and deducts 5 credits per scraped profile", async () => {
const res = await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID }));
expect(res.status).toBe(200);
expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 10);
expect(scrapeProfileUrlBatch).toHaveBeenCalledWith(
[{ profileUrl: "https://x.com/a", username: "a" }],
[
{ profileUrl: "https://x.com/a", username: "a" },
{ profileUrl: "https://youtube.com/@b", username: "b" },
],
undefined,
);
expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 10);
});

it("forwards posts to scrapeProfileUrlBatch", async () => {
it("forwards posts and deducts (5 + posts) per profile actually scraped", async () => {
vi.mocked(scrapeProfileUrlBatch).mockResolvedValue([
{ runId: "r1", datasetId: "d1", error: null },
]);
await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID, posts: 20 }));
expect(scrapeProfileUrlBatch).toHaveBeenCalledWith(
[{ profileUrl: "https://x.com/a", username: "a" }],
20,
);
expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 50);
expect(scrapeProfileUrlBatch).toHaveBeenCalledWith(expect.any(Array), 20);
expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 25);
});

it("returns [] when the artist has no socials", async () => {
it("returns [] and charges nothing when the artist has no socials", async () => {
vi.mocked(selectAccountSocials).mockResolvedValue([]);
const res = await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID }));
expect(res.status).toBe(200);
expect(await res.json()).toEqual([]);
expect(scrapeProfileUrlBatch).not.toHaveBeenCalled();
expect(ensureSocialScrapeCredits).not.toHaveBeenCalled();
expect(deductSocialScrapeCredits).not.toHaveBeenCalled();
});
});
33 changes: 33 additions & 0 deletions lib/artist/postArtistSocialsScrapeHandler.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { errorResponse } from "@/lib/networking/errorResponse";
import { scrapeProfileUrlBatch } from "@/lib/apify/scrapeProfileUrlBatch";
import { validateArtistSocialsScrapeBody } from "@/lib/artist/validateArtistSocialsScrapeBody";
import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess";
import { ensureSocialScrapeCredits } from "@/lib/socials/ensureSocialScrapeCredits";
import { deductSocialScrapeCredits } from "@/lib/socials/deductSocialScrapeCredits";
import { getSocialScrapeCreditCost } from "@/lib/socials/getSocialScrapeCreditCost";

/**
* Handler for scraping artist social profiles.
*
* Body parameters:
* - artist_account_id (required): The unique identifier of the artist account
* - posts (optional): Recent-posts depth forwarded to each platform scraper
*
* Costs 5 credits plus 1 per requested post, per social profile scraped.
* Credits are gated up-front against the number of linked profiles and
* deducted for the profiles whose scrape actually started.
*
* @param request - The request object containing the body with artist_account_id.
* @returns A NextResponse with scraping results.
Expand All @@ -22,6 +33,17 @@ export async function postArtistSocialsScrapeHandler(request: NextRequest): Prom
return validatedBody;
}

const authResult = await validateAuthContext(request);
if (authResult instanceof NextResponse) return authResult;

const hasAccess = await checkAccountArtistAccess(
authResult.accountId,
validatedBody.artist_account_id,
);
if (!hasAccess) {
return errorResponse("Unauthorized artist socials scrape attempt", 403);
}

const socials = await selectAccountSocials({ accountId: validatedBody.artist_account_id });

if (!socials.length) {
Expand All @@ -31,6 +53,13 @@ export async function postArtistSocialsScrapeHandler(request: NextRequest): Prom
});
}

const costPerProfile = getSocialScrapeCreditCost(validatedBody.posts);
const short = await ensureSocialScrapeCredits(
authResult.accountId,
costPerProfile * socials.length,
);
if (short) return short;

const results = await scrapeProfileUrlBatch(
socials.map(social => ({
profileUrl: social.social?.profile_url,
Expand All @@ -39,6 +68,10 @@ export async function postArtistSocialsScrapeHandler(request: NextRequest): Prom
validatedBody.posts,
);

if (results.length) {
await deductSocialScrapeCredits(authResult.accountId, costPerProfile * results.length);
}

return NextResponse.json(results, {
status: 200,
headers: getCorsHeaders(),
Expand Down
28 changes: 28 additions & 0 deletions lib/socials/__tests__/deductSocialScrapeCredits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

import { deductSocialScrapeCredits } from "../deductSocialScrapeCredits";
import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction";

vi.mock("@/lib/credits/recordCreditDeduction", () => ({ recordCreditDeduction: vi.fn() }));

const ACCOUNT_ID = "770e8400-e29b-41d4-a716-446655440000";

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

it("records the deduction with the given credits", async () => {
await deductSocialScrapeCredits(ACCOUNT_ID, 25);
expect(recordCreditDeduction).toHaveBeenCalledWith({
accountId: ACCOUNT_ID,
creditsToDeduct: 25,
source: "api",
});
});

it("never throws when the deduction fails (billing must not fail a started scrape)", async () => {
vi.mocked(recordCreditDeduction).mockRejectedValue(new Error("db down"));
await expect(deductSocialScrapeCredits(ACCOUNT_ID, 5)).resolves.toBeUndefined();
});
});
31 changes: 31 additions & 0 deletions lib/socials/__tests__/ensureSocialScrapeCredits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextResponse } from "next/server";

import { ensureSocialScrapeCredits } from "../ensureSocialScrapeCredits";
import { ensureCreditsOrShortCircuit } from "@/lib/credits/ensureCreditsOrShortCircuit";

vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({
ensureCreditsOrShortCircuit: vi.fn(),
}));

const ACCOUNT_ID = "770e8400-e29b-41d4-a716-446655440000";

describe("ensureSocialScrapeCredits", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(ensureCreditsOrShortCircuit).mockResolvedValue(null);
});

it("gates on the given credit amount", async () => {
expect(await ensureSocialScrapeCredits(ACCOUNT_ID, 25)).toBeNull();
expect(ensureCreditsOrShortCircuit).toHaveBeenCalledWith(
expect.objectContaining({ accountId: ACCOUNT_ID, creditsToDeduct: 25 }),
);
});

it("passes through the 402 short-circuit response", async () => {
const short = NextResponse.json({}, { status: 402 });
vi.mocked(ensureCreditsOrShortCircuit).mockResolvedValue(short);
expect(await ensureSocialScrapeCredits(ACCOUNT_ID, 5)).toBe(short);
});
});
15 changes: 15 additions & 0 deletions lib/socials/__tests__/getSocialScrapeCreditCost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, it, expect } from "vitest";

import { getSocialScrapeCreditCost } from "../getSocialScrapeCreditCost";

describe("getSocialScrapeCreditCost", () => {
it("charges the 5-credit base when posts is omitted", () => {
expect(getSocialScrapeCreditCost(undefined)).toBe(5);
});

it("charges 5 + posts when a depth is requested", () => {
expect(getSocialScrapeCreditCost(1)).toBe(6);
expect(getSocialScrapeCreditCost(20)).toBe(25);
expect(getSocialScrapeCreditCost(100)).toBe(105);
});
});
24 changes: 21 additions & 3 deletions lib/socials/__tests__/postSocialScrapeHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@ import { postSocialScrapeHandler } from "../postSocialScrapeHandler";
import { validatePostSocialScrapeRequest } from "../validatePostSocialScrapeRequest";
import { selectSocials } from "@/lib/supabase/socials/selectSocials";
import { scrapeProfileUrl } from "@/lib/apify/scrapeProfileUrl";
import { deductSocialScrapeCredits } from "../deductSocialScrapeCredits";

vi.mock("../validatePostSocialScrapeRequest", () => ({
validatePostSocialScrapeRequest: vi.fn(),
}));
vi.mock("@/lib/supabase/socials/selectSocials", () => ({ selectSocials: vi.fn() }));
vi.mock("@/lib/apify/scrapeProfileUrl", () => ({ scrapeProfileUrl: vi.fn() }));
vi.mock("../deductSocialScrapeCredits", () => ({ deductSocialScrapeCredits: vi.fn() }));
vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) }));

const SOCIAL_ID = "550e8400-e29b-41d4-a716-446655440000";
const ACCOUNT_ID = "770e8400-e29b-41d4-a716-446655440000";
const request = new NextRequest(`http://localhost/api/socials/${SOCIAL_ID}/scrape`, {
method: "POST",
});
const validated = { social_id: SOCIAL_ID };
const validated = { social_id: SOCIAL_ID, account_id: ACCOUNT_ID };
const social = { id: SOCIAL_ID, profile_url: "https://x.com/a", username: "a" };

describe("postSocialScrapeHandler", () => {
Expand All @@ -40,22 +43,37 @@ describe("postSocialScrapeHandler", () => {
expect(res.status).toBe(404);
});

it("returns 200 with { runId, datasetId } on success", async () => {
it("returns 200 with { runId, datasetId } on success and deducts the base 5 credits", async () => {
vi.mocked(scrapeProfileUrl).mockResolvedValue({ runId: "r1", datasetId: "d1" } as never);
const res = await postSocialScrapeHandler(request, SOCIAL_ID);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ runId: "r1", datasetId: "d1" });
expect(scrapeProfileUrl).toHaveBeenCalledWith(social.profile_url, social.username, undefined);
expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 5);
});

it("forwards validated posts to scrapeProfileUrl", async () => {
it("forwards validated posts to scrapeProfileUrl and deducts 5 + posts credits", async () => {
vi.mocked(validatePostSocialScrapeRequest).mockResolvedValue({
social_id: SOCIAL_ID,
account_id: ACCOUNT_ID,
posts: 20,
});
vi.mocked(scrapeProfileUrl).mockResolvedValue({ runId: "r1", datasetId: "d1" } as never);
await postSocialScrapeHandler(request, SOCIAL_ID);
expect(scrapeProfileUrl).toHaveBeenCalledWith(social.profile_url, social.username, 20);
expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 25);
});

it("does not deduct credits when the scrape fails to start", async () => {
vi.mocked(scrapeProfileUrl).mockResolvedValue({ error: "boom" } as never);
await postSocialScrapeHandler(request, SOCIAL_ID);
expect(deductSocialScrapeCredits).not.toHaveBeenCalled();
});

it("does not deduct credits when the platform is unsupported", async () => {
vi.mocked(scrapeProfileUrl).mockResolvedValue(null as never);
await postSocialScrapeHandler(request, SOCIAL_ID);
expect(deductSocialScrapeCredits).not.toHaveBeenCalled();
});

it("returns 400 when platform unsupported (scrapeProfileUrl returns null)", async () => {
Expand Down
15 changes: 14 additions & 1 deletion lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { selectSocials } from "@/lib/supabase/socials/selectSocials";
import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials";
import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess";
import { ensureSocialScrapeCredits } from "../ensureSocialScrapeCredits";

vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() }));
vi.mock("@/lib/supabase/socials/selectSocials", () => ({ selectSocials: vi.fn() }));
Expand All @@ -15,6 +16,7 @@ vi.mock("@/lib/supabase/account_socials/selectAccountSocials", () => ({
vi.mock("@/lib/artists/checkAccountArtistAccess", () => ({
checkAccountArtistAccess: vi.fn(),
}));
vi.mock("../ensureSocialScrapeCredits", () => ({ ensureSocialScrapeCredits: vi.fn() }));
vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) }));

const SOCIAL_ID = "550e8400-e29b-41d4-a716-446655440000";
Expand All @@ -35,6 +37,7 @@ describe("validatePostSocialScrapeRequest", () => {
vi.mocked(selectSocials).mockResolvedValue([{ id: SOCIAL_ID } as never]);
vi.mocked(selectAccountSocials).mockResolvedValue([{ account_id: ARTIST_ID } as never]);
vi.mocked(checkAccountArtistAccess).mockResolvedValue(true);
vi.mocked(ensureSocialScrapeCredits).mockResolvedValue(null);
});

it.each([["not-a-uuid"], [""], ["123"]])("returns 400 for invalid uuid %s", async id => {
Expand Down Expand Up @@ -74,18 +77,28 @@ describe("validatePostSocialScrapeRequest", () => {
expect(await validatePostSocialScrapeRequest(makeRequest(), SOCIAL_ID)).toEqual({
social_id: SOCIAL_ID,
posts: undefined,
account_id: ACCOUNT_ID,
});
expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 5);
});

it("parses a valid posts query param", async () => {
it("parses a valid posts query param and gates on 5 + posts credits", async () => {
const req = new NextRequest(`http://localhost/api/socials/${SOCIAL_ID}/scrape?posts=20`, {
method: "POST",
headers: { "x-api-key": "k" },
});
expect(await validatePostSocialScrapeRequest(req, SOCIAL_ID)).toEqual({
social_id: SOCIAL_ID,
posts: 20,
account_id: ACCOUNT_ID,
});
expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 25);
});

it("short-circuits with the 402 when credits are insufficient", async () => {
const short = NextResponse.json({}, { status: 402 });
vi.mocked(ensureSocialScrapeCredits).mockResolvedValue(short);
expect(await validatePostSocialScrapeRequest(makeRequest(), SOCIAL_ID)).toBe(short);
});

it.each([["0"], ["101"], ["abc"], ["1.5"]])(
Expand Down
Loading
Loading