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
50 changes: 36 additions & 14 deletions lib/apify/__tests__/sendApifyWebhookEmail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendApifyWebhookEmail } from "@/lib/apify/sendApifyWebhookEmail";
import { RECOUP_FROM_EMAIL } from "@/lib/const";

vi.mock("@/lib/ai/generateText", () => ({
default: vi.fn(async () => ({ text: "<p>body</p>" })),
}));
const sendEmailWithResend = vi.fn();
vi.mock("@/lib/emails/sendEmail", () => ({
sendEmailWithResend: (...a: unknown[]) => sendEmailWithResend(...a),
}));
vi.mock("@/lib/composio/getFrontendBaseUrl", () => ({
getFrontendBaseUrl: () => "https://chat.recoupable.dev",
}));

const PROFILE = {
fullName: "Ashnikko",
Expand All @@ -18,8 +18,16 @@ const PROFILE = {
biography: "bio",
followersCount: 100,
followsCount: 10,
latestPosts: [],
latestPosts: [
{
url: "https://instagram.com/p/new1",
caption: "New tour dates",
displayUrl: "https://cdn.ig/new1.jpg",
timestamp: "2026-07-08T12:00:00.000Z",
},
],
} as never;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Enforce Clear Code Style and Maintainability Practices

The PROFILE test fixture uses as never which completely erases type checking on this object. Since ApifyInstagramProfileResult has all-optional fields, the literal already satisfies the interface — no assertion is needed. Removing as never (or switching to as ApifyInstagramProfileResult if you want explicit annotation) would let TypeScript verify the fixture stays in sync with the type definition.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/__tests__/sendApifyWebhookEmail.test.ts, line 22:

<comment>The PROFILE test fixture uses `as never` which completely erases type checking on this object. Since `ApifyInstagramProfileResult` has all-optional fields, the literal already satisfies the interface — no assertion is needed. Removing `as never` (or switching to `as ApifyInstagramProfileResult` if you want explicit annotation) would let TypeScript verify the fixture stays in sync with the type definition.</comment>

<file context>
@@ -0,0 +1,62 @@
+  followersCount: 100,
+  followsCount: 10,
+  latestPosts: [],
+} as never;
+const NEW_URLS = ["https://instagram.com/p/new1"];
+
</file context>

const NEW_URLS = ["https://instagram.com/p/new1"];

beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -28,27 +36,41 @@ beforeEach(() => {

describe("sendApifyWebhookEmail", () => {
it("BCCs recipients so no account can see another's address (chat#1855)", async () => {
const recipients = [
"manager@customer-a.com",
"label@customer-b.com",
"internal@recoupable.com",
];
await sendApifyWebhookEmail(PROFILE, recipients);
const recipients = ["manager@customer-a.com", "label@customer-b.com"];
await sendApifyWebhookEmail(PROFILE, recipients, NEW_URLS);
const payload = sendEmailWithResend.mock.calls[0][0] as Record<string, unknown>;
expect(payload.bcc).toEqual(recipients);
expect(payload.to).toEqual([RECOUP_FROM_EMAIL]);
});

it("never carries more than our own address in to/cc, regardless of recipient count", async () => {
const recipients = Array.from({ length: 25 }, (_, i) => `user${i}@example.com`);
await sendApifyWebhookEmail(PROFILE, recipients);
await sendApifyWebhookEmail(PROFILE, recipients, NEW_URLS);
const payload = sendEmailWithResend.mock.calls[0][0] as Record<string, unknown>;
const visible = [payload.to, payload.cc].flat().filter(Boolean);
expect(visible).toEqual([RECOUP_FROM_EMAIL]);
expect([payload.to, payload.cc].flat().filter(Boolean)).toEqual([RECOUP_FROM_EMAIL]);
});

it("links the new posts in the deterministic body — no vendor jargon", async () => {
await sendApifyWebhookEmail(PROFILE, ["a@b.com"], NEW_URLS);
const payload = sendEmailWithResend.mock.calls[0][0] as Record<string, string>;
expect(payload.html).toContain('href="https://instagram.com/p/new1"');
expect((payload.html + payload.subject).toLowerCase()).not.toContain("apify");
});

it("enriches the body with the post's media and caption from the dataset in hand", async () => {
await sendApifyWebhookEmail(PROFILE, ["a@b.com"], NEW_URLS);
const payload = sendEmailWithResend.mock.calls[0][0] as Record<string, string>;
expect(payload.html).toContain('src="https://cdn.ig/new1.jpg"');
expect(payload.html).toContain("New tour dates");
});

it("returns null and sends nothing when there are no recipients", async () => {
expect(await sendApifyWebhookEmail(PROFILE, [])).toBeNull();
expect(await sendApifyWebhookEmail(PROFILE, [], NEW_URLS)).toBeNull();
expect(sendEmailWithResend).not.toHaveBeenCalled();
});

it("returns null and sends nothing when no post is genuinely new", async () => {
expect(await sendApifyWebhookEmail(PROFILE, ["a@b.com"], [])).toBeNull();
expect(sendEmailWithResend).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { extractArtistNameFromDatasetItems } from "@/lib/apify/digest/extractArtistNameFromDatasetItems";

describe("extractArtistNameFromDatasetItems", () => {
it("uses the Instagram profile fullName, falling back to username", () => {
expect(
extractArtistNameFromDatasetItems("instagram", [{ fullName: "National Geographic" }]),
).toBe("National Geographic");
expect(extractArtistNameFromDatasetItems("instagram", [{ username: "natgeo" }])).toBe("natgeo");
});

it("uses the TikTok author nickName, falling back to handle", () => {
expect(
extractArtistNameFromDatasetItems("tiktok", [
{ authorMeta: { name: "natgeo", nickName: "National Geographic" } },
]),
).toBe("National Geographic");
expect(extractArtistNameFromDatasetItems("tiktok", [{ authorMeta: { name: "natgeo" } }])).toBe(
"natgeo",
);
});

it("returns null for unknown platforms or empty items", () => {
expect(extractArtistNameFromDatasetItems("youtube", [{ some: "shape" }])).toBeNull();
expect(extractArtistNameFromDatasetItems("instagram", [])).toBeNull();
});
});

describe("extractArtistNameFromDatasetItems — twitter", () => {
it("uses the tweet author's display name, falling back to handle", () => {
expect(
extractArtistNameFromDatasetItems("twitter", [
{ author: { name: "Sweetman", userName: "sweetman_eth" } },
]),
).toBe("Sweetman");
expect(extractArtistNameFromDatasetItems("x", [{ author: { userName: "sweetman_eth" } }])).toBe(
"sweetman_eth",
);
});
});
127 changes: 127 additions & 0 deletions lib/apify/digest/__tests__/extractPostsFromDatasetItems.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import { extractPostsFromDatasetItems } from "@/lib/apify/digest/extractPostsFromDatasetItems";

const IG_ITEMS = [
{
latestPosts: [
{
url: "https://instagram.com/p/new1",
caption: "cap one",
displayUrl: "https://cdn.ig/new1.jpg",
timestamp: "2026-07-08T12:00:00.000Z",
likesCount: 100,
commentsCount: 5,
videoViewCount: 9000,
},
{
url: "https://instagram.com/p/old",
caption: "old",
displayUrl: "https://cdn.ig/old.jpg",
timestamp: "2026-06-01T00:00:00.000Z",
},
],
},
];

const TIKTOK_ITEMS = [
{
webVideoUrl: "https://tiktok.com/@a/video/1",
text: "tt caption",
createTimeISO: "2026-07-09T09:00:00.000Z",
videoMeta: { coverUrl: "https://cdn.tt/1.jpg" },
diggCount: 200,
commentCount: 10,
playCount: 50000,
shareCount: 7,
},
{
webVideoUrl: "https://tiktok.com/@a/video/2",
text: "known",
createTimeISO: "2026-01-01T00:00:00.000Z",
},
];

describe("extractPostsFromDatasetItems", () => {
it("maps Instagram latestPosts limited to the new URLs, with media", () => {
const posts = extractPostsFromDatasetItems("instagram", IG_ITEMS, [
"https://instagram.com/p/new1",
]);
expect(posts).toEqual([
{
url: "https://instagram.com/p/new1",
caption: "cap one",
thumbnailUrl: "https://cdn.ig/new1.jpg",
timestamp: "2026-07-08T12:00:00.000Z",
stats: { likes: 100, comments: 5, views: 9000 },
},
]);
});

it("maps TikTok items limited to the new URLs, with cover media", () => {
const posts = extractPostsFromDatasetItems("tiktok", TIKTOK_ITEMS, [
"https://tiktok.com/@a/video/1",
]);
expect(posts).toEqual([
{
url: "https://tiktok.com/@a/video/1",
caption: "tt caption",
thumbnailUrl: "https://cdn.tt/1.jpg",
timestamp: "2026-07-09T09:00:00.000Z",
stats: { likes: 200, comments: 10, views: 50000, shares: 7 },
},
]);
});

it("falls back to URL-only posts for platforms without an extractor", () => {
const posts = extractPostsFromDatasetItems(
"youtube",
[{ some: "shape" }],
["https://youtube.com/watch?v=1"],
);
expect(posts).toEqual([{ url: "https://youtube.com/watch?v=1" }]);
});

it("falls back to URL-only for new URLs missing from the dataset items", () => {
const posts = extractPostsFromDatasetItems("instagram", IG_ITEMS, [
"https://instagram.com/p/new1",
"https://instagram.com/p/not-in-items",
]);
expect(posts).toHaveLength(2);
expect(posts[1]).toEqual({ url: "https://instagram.com/p/not-in-items" });
});
});

const TWEET_ITEMS = [
{
url: "https://x.com/a/status/1",
fullText: "New single out on all platforms",
createdAt: "Wed Jul 08 19:43:50 +0000 2026",
likeCount: 5,
replyCount: 1,
retweetCount: 2,
viewCount: 748,
extendedEntities: { media: [{ media_url_https: "https://pbs.twimg.com/media/x.jpg" }] },
},
];

describe("extractPostsFromDatasetItems — twitter", () => {
it("maps tweets limited to the new URLs, with media and stats", () => {
const posts = extractPostsFromDatasetItems("twitter", TWEET_ITEMS, [
"https://x.com/a/status/1",
]);
expect(posts).toEqual([
{
url: "https://x.com/a/status/1",
caption: "New single out on all platforms",
thumbnailUrl: "https://pbs.twimg.com/media/x.jpg",
timestamp: "2026-07-08T19:43:50.000Z",
stats: { likes: 5, comments: 1, views: 748, shares: 2 },
},
]);
});

it("maps the x platform alias identically", () => {
const posts = extractPostsFromDatasetItems("x", TWEET_ITEMS, ["https://x.com/a/status/1"]);
expect(posts[0].caption).toBe("New single out on all platforms");
});
});
21 changes: 19 additions & 2 deletions lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ vi.mock("@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns", () => ({
vi.mock("@/lib/apify/digest/getScrapeDigestRecipients", () => ({
getScrapeDigestRecipients: vi.fn(),
}));
vi.mock("@/lib/apify/digest/getRunDigestSection", () => ({
// URL-only translation of a run row — enrichment is unit-tested separately
getRunDigestSection: vi.fn(async (run: { platform: string; new_post_urls: string[] | null }) =>
run.new_post_urls?.length
? {
platform: run.platform,
posts: run.new_post_urls.map((url: string) => ({ url })),
artistName: run.platform === "instagram" ? "National Geographic" : null,
}
: null,
),
}));
vi.mock("@/lib/apify/digest/sendScrapeDigestEmail", () => ({
sendScrapeDigestEmail: vi.fn(),
}));
Expand Down Expand Up @@ -51,10 +63,15 @@ describe("maybeSendScrapeDigest", () => {
expect(sendScrapeDigestEmail).toHaveBeenCalledOnce();
const arg = vi.mocked(sendScrapeDigestEmail).mock.calls[0][0];
expect(arg.sections).toEqual([
{ platform: "instagram", postUrls: ["https://instagram.com/p/1"] },
{ platform: "tiktok", postUrls: ["https://tiktok.com/v/2"] },
{
platform: "instagram",
posts: [{ url: "https://instagram.com/p/1" }],
artistName: "National Geographic",
},
{ platform: "tiktok", posts: [{ url: "https://tiktok.com/v/2" }], artistName: null },
]); // x omitted — nothing new
expect(arg.emails).toEqual(["owner@example.com"]);
expect(arg.artistName).toBe("National Geographic"); // digest addressed by artist, not "Your artist"
});

it("sends nothing when the batch completes with zero new posts anywhere", async () => {
Expand Down
Loading
Loading