Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
f99277e
feat(measurement-jobs): free-tier card gate (setup mode) + instant ba…
sweetmantech Jun 16, 2026
158a1d4
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
5fa5e3a
fix(songstats-backfill): backoff on 429 + defer instead of churn (cha…
sweetmantech Jun 16, 2026
1e9deac
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
5f45946
refactor(songstats): remove local quota ledger + budget gate (chat#17…
sweetmantech Jun 16, 2026
5472795
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
f24d4c7
feat: POST /api/catalogs (create + materialize from valuation snapsho…
sweetmantech Jun 18, 2026
ace0b01
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
a520a1d
fix: LEFT-join artists in catalog-songs read (materialized tracks wer…
sweetmantech Jun 18, 2026
76b5739
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
709ea0c
feat: add X (Twitter) + LinkedIn to the Composio connector whitelist …
sweetmantech Jun 18, 2026
8a3c3cb
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
66cc2fe
chore: remove unused ALLOWED_ARTIST_CONNECTORS from api (chat#1793) (…
sweetmantech Jun 18, 2026
79c22da
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
6fc10cc
fix: enrich valuation-captured songs (artists + notes) so they render…
sweetmantech Jun 18, 2026
0e42d02
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
2fa7029
fix(tasks): let admins fetch any task by id alone (cross-account read…
sweetmantech Jun 19, 2026
bd2ae10
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 19, 2026
cdb34d0
feat(connectors): POST /api/connectors/files — stage images for Linke…
sweetmantech Jun 20, 2026
acae4d8
Merge main into test (sync #692 Songstats work)
sweetmantech Jun 23, 2026
2a7af68
feat(artists): account_id override for DELETE /api/artists/{id} (#693)
sweetmantech Jun 23, 2026
8c962ef
Merge main into test (sync #696)
sweetmantech Jun 23, 2026
8b6a3bb
feat(chats): admins (RECOUP_ORG) can access any chat — read + write (…
sweetmantech Jun 23, 2026
03e0ef5
Enforce account_api_keys.expires_at in x-api-key auth (chat#1813) (#700)
sweetmantech Jun 24, 2026
bff29ae
Merge branch 'main' into test
sweetmantech Jun 24, 2026
78bd71b
refactor(chat): extract shared buildRunAgentInput (chat#1813) (#701)
sweetmantech Jun 24, 2026
2209b7d
Re-point POST /api/chat/generate onto runAgentWorkflow + ephemeral ke…
sweetmantech Jun 24, 2026
af7eaa5
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 24, 2026
b84e4a1
Merge remote-tracking branch 'origin/main' into HEAD
sweetmantech Jun 24, 2026
4c09cf0
Retire OpenClaw prompt_sandbox → run-sandbox-command bridge (chat#181…
sweetmantech Jun 24, 2026
f1ade64
Merge remote-tracking branch 'origin/main' into HEAD
sweetmantech Jun 24, 2026
4ecca44
feat: POST /api/emails + route ephemeral key to RECOUP_API_KEY (#1815…
sweetmantech Jun 25, 2026
27f0532
Merge origin/main into test (sync after #709 test→main promotion)
sweetmantech Jun 25, 2026
6d9e74e
fix(skills): install the renamed global skills into sandboxes (chat#1…
sweetmantech Jun 25, 2026
a463e59
Merge origin/main into test (sync after #713 test→main promotion)
sweetmantech Jun 25, 2026
18d20f7
feat(emails): make `to` and `subject` optional on POST /api/emails (#…
sweetmantech Jun 25, 2026
d981536
chore: remove POST /api/notifications (superseded by /api/emails) (#711)
sweetmantech Jun 25, 2026
cae9f35
Merge origin/main into test (sync for #715 promotion)
sweetmantech Jun 25, 2026
be2abcc
fix: repoint dead .com hosts to live .dev (recoupable/chat#1819 §A+§B…
sweetmantech Jun 29, 2026
0e0bac0
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 29, 2026
e6610b4
fix: repoint remaining .com refs missed by api#719 (domain cutover) (…
sweetmantech Jun 30, 2026
bdcd796
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 30, 2026
c5e3e1f
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 30, 2026
8a3d084
fix(chat/runs): install global skills in the headless run path (#722)
sweetmantech Jun 30, 2026
71c920e
Merge origin/main into test (sync after #727 test->main release)
sweetmantech Jun 30, 2026
d7b06ce
HOLD MERGE — api brand email → @recoupable.dev (+ inbound @mail.recou…
sweetmantech Jun 30, 2026
fba3b60
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 30, 2026
cd74805
Merge origin/main into test (sync direct-to-main merges: #729 guard, …
sweetmantech Jul 1, 2026
868fcef
Merge origin/main into test (sync #734 /api/artists id-consistency di…
sweetmantech Jul 1, 2026
8b1998e
Merge origin/main into test (sync #735 /socials id-consistency direct…
sweetmantech Jul 1, 2026
c2d9fb2
Merge origin/main into test (sync #737 LinkedIn scraper direct-to-mai…
sweetmantech Jul 1, 2026
53c9028
Merge origin/main into test (sync #738 registry + LinkedIn persistenc…
sweetmantech Jul 1, 2026
791e393
Merge origin/main into test (sync #740 remaining-platforms persistenc…
sweetmantech Jul 2, 2026
e734c61
Merge origin/main into test (sync #742 headless artist-context direct…
sweetmantech Jul 2, 2026
4290c72
feat(apify): optional posts depth on social scrape endpoints (#741)
sweetmantech Jul 2, 2026
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
6 changes: 5 additions & 1 deletion lib/apify/__tests__/scrapeProfileUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@ beforeEach(() => {
describe("scrapeProfileUrl", () => {
it("dispatches linkedin.com to the LinkedIn scraper", async () => {
const r = await scrapeProfileUrl("https://www.linkedin.com/in/drew-thurlow", "drew-thurlow");
expect(li).toHaveBeenCalledWith("drew-thurlow");
expect(li).toHaveBeenCalledWith("drew-thurlow", undefined);
expect(r).toMatchObject({ supported: true, runId: "li-run", datasetId: "li-ds" });
});
it("forwards posts to the platform scraper", async () => {
await scrapeProfileUrl("https://www.linkedin.com/in/drew-thurlow", "drew-thurlow", 20);
expect(li).toHaveBeenCalledWith("drew-thurlow", 20);
});
it("returns null for an unsupported platform (spotify)", async () => {
const r = await scrapeProfileUrl("https://open.spotify.com/artist/x", "x");
expect(r).toBeNull();
Expand Down
35 changes: 35 additions & 0 deletions lib/apify/__tests__/scrapeProfileUrlBatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

import { scrapeProfileUrlBatch } from "@/lib/apify/scrapeProfileUrlBatch";
import { scrapeProfileUrl } from "@/lib/apify/scrapeProfileUrl";

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

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(scrapeProfileUrl).mockResolvedValue({
runId: "r1",
datasetId: "d1",
error: null,
supported: true,
});
});

describe("scrapeProfileUrlBatch", () => {
it("forwards posts to every scrapeProfileUrl call", async () => {
await scrapeProfileUrlBatch(
[
{ profileUrl: "https://x.com/a", username: "a" },
{ profileUrl: "https://youtube.com/@b", username: "b" },
],
20,
);
expect(scrapeProfileUrl).toHaveBeenCalledWith("https://x.com/a", "a", 20);
expect(scrapeProfileUrl).toHaveBeenCalledWith("https://youtube.com/@b", "b", 20);
});

it("passes posts as undefined when omitted (legacy behavior)", async () => {
await scrapeProfileUrlBatch([{ profileUrl: "https://x.com/a", username: "a" }]);
expect(scrapeProfileUrl).toHaveBeenCalledWith("https://x.com/a", "a", undefined);
});
});
8 changes: 6 additions & 2 deletions lib/apify/scrapeProfileUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import startFacebookProfileScraping from "@/lib/apify/facebook/startFacebookProf
import startLinkedinProfileScraping from "@/lib/apify/linkedin/startLinkedinProfileScraping";
import { getUsernameFromProfileUrl } from "@/lib/socials/getUsernameFromProfileUrl";

type ScrapeRunner = (handle: string) => Promise<{
type ScrapeRunner = (
handle: string,
posts?: number,
) => Promise<{
runId: string;
datasetId: string;
error?: string;
Expand Down Expand Up @@ -61,6 +64,7 @@ const PLATFORM_SCRAPERS: Array<{
export const scrapeProfileUrl = async (
profileUrl: string | null | undefined,
username: string,
posts?: number,
): Promise<ScrapeProfileResult | null> => {
if (!profileUrl) {
return null;
Expand All @@ -76,7 +80,7 @@ export const scrapeProfileUrl = async (
const finalUsername = username || getUsernameFromProfileUrl(profileUrl);

try {
const result = await platform.scraper(finalUsername);
const result = await platform.scraper(finalUsername, posts);

if (!result) {
return {
Expand Down
5 changes: 4 additions & 1 deletion lib/apify/scrapeProfileUrlBatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ type ScrapeProfileUrlBatchInput = {

export const scrapeProfileUrlBatch = async (
inputs: ScrapeProfileUrlBatchInput[],
posts?: number,
): Promise<ProfileScrapeResult[]> => {
const results = await Promise.all(
inputs.map(({ profileUrl, username }) => scrapeProfileUrl(profileUrl ?? null, username ?? "")),
inputs.map(({ profileUrl, username }) =>
scrapeProfileUrl(profileUrl ?? null, username ?? "", posts),
),
);

return results
Expand Down
36 changes: 36 additions & 0 deletions lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import apifyClient from "@/lib/apify/client";
import startTwitterProfileScraping from "@/lib/apify/twitter/startTwitterProfileScraping";

const start = vi.fn();
vi.mock("@/lib/apify/client", () => ({ default: { actor: vi.fn(() => ({ start })) } }));

beforeEach(() => {
vi.clearAllMocks();
start.mockResolvedValue({ id: "run-1", defaultDatasetId: "ds-1", status: "RUNNING" });
});

describe("startTwitterProfileScraping", () => {
it("defaults to the legacy single-item snapshot (maxItems: 1) when posts is omitted", async () => {
const r = await startTwitterProfileScraping("sweetman_eth");
expect(apifyClient.actor).toHaveBeenCalledWith("apidojo/twitter-scraper-lite");
expect(start).toHaveBeenCalledWith(
{ twitterHandles: ["sweetman_eth"], sort: "Latest", maxItems: 1 },
{ webhooks: expect.any(Array) },
);
expect(r).toEqual({ runId: "run-1", datasetId: "ds-1" });
});

it("passes posts through as maxItems", async () => {
await startTwitterProfileScraping("sweetman_eth", 20);
expect(start).toHaveBeenCalledWith(
{ twitterHandles: ["sweetman_eth"], sort: "Latest", maxItems: 20 },
{ webhooks: expect.any(Array) },
);
});

it("throws on an empty handle", async () => {
await expect(startTwitterProfileScraping(" ")).rejects.toThrow(/Invalid Twitter handle/);
expect(start).not.toHaveBeenCalled();
});
});
7 changes: 5 additions & 2 deletions lib/apify/twitter/startTwitterProfileScraping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import apifyClient from "@/lib/apify/client";
import { getApifyWebhooks } from "@/lib/apify/getApifyWebhooks";
import { ApifyRunInfo } from "@/lib/apify/types";

const startTwitterProfileScraping = async (handle: string): Promise<ApifyRunInfo | null> => {
const startTwitterProfileScraping = async (
handle: string,
posts?: number,
): Promise<ApifyRunInfo | null> => {
const cleanHandle = handle.trim();

if (!cleanHandle) {
Expand All @@ -12,7 +15,7 @@ const startTwitterProfileScraping = async (handle: string): Promise<ApifyRunInfo
const input = {
twitterHandles: [cleanHandle],
sort: "Latest",
maxItems: 1,
maxItems: posts ?? 1,
};

const run = await apifyClient
Expand Down
44 changes: 44 additions & 0 deletions lib/apify/youtube/__tests__/startYoutubeProfileScraping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import apifyClient from "@/lib/apify/client";
import startYoutubeProfileScraping from "@/lib/apify/youtube/startYoutubeProfileScraping";

const start = vi.fn();
vi.mock("@/lib/apify/client", () => ({ default: { actor: vi.fn(() => ({ start })) } }));

beforeEach(() => {
vi.clearAllMocks();
start.mockResolvedValue({ id: "run-1", defaultDatasetId: "ds-1", status: "RUNNING" });
});

describe("startYoutubeProfileScraping", () => {
it("defaults to the legacy snapshot (maxResults: 1, Shorts excluded) when posts is omitted", async () => {
const r = await startYoutubeProfileScraping("@mycowtf");
expect(apifyClient.actor).toHaveBeenCalledWith("streamers/youtube-scraper");
expect(start).toHaveBeenCalledWith(
expect.objectContaining({
startUrls: [{ url: "https://www.youtube.com/@mycowtf" }],
maxResults: 1,
maxResultsShorts: 0,
}),
{ webhooks: expect.any(Array) },
);
expect(r).toEqual({ runId: "run-1", datasetId: "ds-1" });
});

it("passes posts through as maxResults AND maxResultsShorts (Shorts included)", async () => {
await startYoutubeProfileScraping("mycowtf", 20);
expect(start).toHaveBeenCalledWith(
expect.objectContaining({
startUrls: [{ url: "https://www.youtube.com/@mycowtf" }],
maxResults: 20,
maxResultsShorts: 20,
}),
{ webhooks: expect.any(Array) },
);
});

it("throws on an empty handle", async () => {
await expect(startYoutubeProfileScraping(" ")).rejects.toThrow(/Invalid YouTube handle/);
expect(start).not.toHaveBeenCalled();
});
});
8 changes: 7 additions & 1 deletion lib/apify/youtube/startYoutubeProfileScraping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ const DEFAULT_INPUT = {
subtitlesFormat: "srt",
};

const startYoutubeProfileScraping = async (handle: string): Promise<ApifyRunInfo | null> => {
const startYoutubeProfileScraping = async (
handle: string,
posts?: number,
): Promise<ApifyRunInfo | null> => {
const cleanHandle = handle.trim().replace(/^@/, "");

if (!cleanHandle) {
Expand All @@ -36,6 +39,9 @@ const startYoutubeProfileScraping = async (handle: string): Promise<ApifyRunInfo
const input = {
...DEFAULT_INPUT,
startUrls: [{ url: targetUrl }],
// The legacy snapshot excludes Shorts; a requested posts depth includes them.
maxResults: posts ?? DEFAULT_INPUT.maxResults,
maxResultsShorts: posts ?? DEFAULT_INPUT.maxResultsShorts,
};

const run = await apifyClient
Expand Down
63 changes: 63 additions & 0 deletions lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";

import { postArtistSocialsScrapeHandler } from "../postArtistSocialsScrapeHandler";
import { scrapeProfileUrlBatch } from "@/lib/apify/scrapeProfileUrlBatch";
import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials";

vi.mock("@/lib/apify/scrapeProfileUrlBatch", () => ({ scrapeProfileUrlBatch: vi.fn() }));
vi.mock("@/lib/supabase/account_socials/selectAccountSocials", () => ({
selectAccountSocials: vi.fn(),
}));
vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) }));

const ARTIST_ID = "660e8400-e29b-41d4-a716-446655440000";

const makeRequest = (body: unknown) =>
new NextRequest("http://localhost/api/artist/socials/scrape", {
method: "POST",
body: JSON.stringify(body),
});

describe("postArtistSocialsScrapeHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(selectAccountSocials).mockResolvedValue([
{ social: { profile_url: "https://x.com/a", username: "a" } } as never,
]);
vi.mocked(scrapeProfileUrlBatch).mockResolvedValue([
{ runId: "r1", datasetId: "d1", error: null },
]);
});

it("returns 400 when artist_account_id is missing", async () => {
const res = await postArtistSocialsScrapeHandler(makeRequest({}));
expect(res.status).toBe(400);
expect(scrapeProfileUrlBatch).not.toHaveBeenCalled();
});

it("scrapes without a posts depth by default (legacy behavior)", async () => {
const res = await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID }));
expect(res.status).toBe(200);
expect(scrapeProfileUrlBatch).toHaveBeenCalledWith(
[{ profileUrl: "https://x.com/a", username: "a" }],
undefined,
);
});

it("forwards posts to scrapeProfileUrlBatch", async () => {
await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID, posts: 20 }));
expect(scrapeProfileUrlBatch).toHaveBeenCalledWith(
[{ profileUrl: "https://x.com/a", username: "a" }],
20,
);
});

it("returns [] 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();
});
});
36 changes: 36 additions & 0 deletions lib/artist/__tests__/validateArtistSocialsScrapeBody.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from "vitest";
import { NextResponse } from "next/server";

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

vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) }));

const ARTIST_ID = "660e8400-e29b-41d4-a716-446655440000";

describe("validateArtistSocialsScrapeBody", () => {
it("returns 400 when artist_account_id is missing", () => {
const res = validateArtistSocialsScrapeBody({}) as NextResponse;
expect(res.status).toBe(400);
});

it("accepts a body without posts (legacy behavior)", () => {
expect(validateArtistSocialsScrapeBody({ artist_account_id: ARTIST_ID })).toEqual({
artist_account_id: ARTIST_ID,
});
});

it("accepts a valid posts field", () => {
expect(validateArtistSocialsScrapeBody({ artist_account_id: ARTIST_ID, posts: 20 })).toEqual({
artist_account_id: ARTIST_ID,
posts: 20,
});
});

it.each([[0], [101], [1.5], ["20"]])("returns 400 for invalid posts %s", posts => {
const res = validateArtistSocialsScrapeBody({
artist_account_id: ARTIST_ID,
posts,
}) as NextResponse;
expect(res.status).toBe(400);
});
});
1 change: 1 addition & 0 deletions lib/artist/postArtistSocialsScrapeHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export async function postArtistSocialsScrapeHandler(request: NextRequest): Prom
profileUrl: social.social?.profile_url,
username: social.social?.username,
})),
validatedBody.posts,
);

return NextResponse.json(results, {
Expand Down
6 changes: 6 additions & 0 deletions lib/artist/validateArtistSocialsScrapeBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { z } from "zod";

export const artistSocialsScrapeBodySchema = z.object({
artist_account_id: z.string().min(1, "artist_account_id body parameter is required"),
posts: z
.number()
.int("posts must be an integer")
.min(1, "posts must be between 1 and 100")
.max(100, "posts must be between 1 and 100")
.optional(),
});

export type ArtistSocialsScrapeBody = z.infer<typeof artistSocialsScrapeBodySchema>;
Expand Down
11 changes: 11 additions & 0 deletions lib/socials/__tests__/postSocialScrapeHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ describe("postSocialScrapeHandler", () => {
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);
});

it("forwards validated posts to scrapeProfileUrl", async () => {
vi.mocked(validatePostSocialScrapeRequest).mockResolvedValue({
social_id: SOCIAL_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);
});

it("returns 400 when platform unsupported (scrapeProfileUrl returns null)", async () => {
Expand Down
25 changes: 25 additions & 0 deletions lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,34 @@ describe("validatePostSocialScrapeRequest", () => {
it("returns validated payload when caller has access to an owning artist", async () => {
expect(await validatePostSocialScrapeRequest(makeRequest(), SOCIAL_ID)).toEqual({
social_id: SOCIAL_ID,
posts: undefined,
});
});

it("parses a valid posts query param", 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,
});
});

it.each([["0"], ["101"], ["abc"], ["1.5"]])(
"returns 400 for invalid posts query param %s",
async posts => {
const req = new NextRequest(
`http://localhost/api/socials/${SOCIAL_ID}/scrape?posts=${posts}`,
{ method: "POST", headers: { "x-api-key": "k" } },
);
const res = (await validatePostSocialScrapeRequest(req, SOCIAL_ID)) as NextResponse;
expect(res.status).toBe(400);
expect(validateAuthContext).not.toHaveBeenCalled();
},
);

it("propagates DB error from selectAccountSocials (fails closed as 500)", async () => {
vi.mocked(selectAccountSocials).mockRejectedValue(new Error("db blew up"));
await expect(validatePostSocialScrapeRequest(makeRequest(), SOCIAL_ID)).rejects.toThrow(
Expand Down
Loading
Loading