diff --git a/app/(app)/projects/[id]/social/page.tsx b/app/(app)/projects/[id]/social/page.tsx index 65999e1b..e046fc97 100644 --- a/app/(app)/projects/[id]/social/page.tsx +++ b/app/(app)/projects/[id]/social/page.tsx @@ -4,6 +4,7 @@ import { createClient } from "@/lib/supabase/server"; import { PostNowForm } from "./post-now"; import { RetryPostButton } from "./retry-post-button"; import { RetryFeedItemButton } from "./retry-feed-item-button"; +import { VerificationCodeInput } from "@/components/verification-code-input"; import { FeedSettingsForm } from "./feed-settings"; import { SocialAutoRefresh } from "./auto-refresh"; import { SocialProfileForm, type SocialProfile } from "./social-profile"; @@ -79,7 +80,7 @@ export default async function SocialDashboardPage({ supabase .from("sp_post") .select( - "id, account_id, rendered_text, source, status, published_at, platform_post_url, last_error, created_at", + "id, account_id, rendered_text, source, status, published_at, platform_post_url, last_error, created_at, verification_prompt", ) .eq("user_id", user.id) .eq("project_id", projectId) @@ -141,7 +142,10 @@ export default async function SocialDashboardPage({ // Only auto-refresh while something is actively moving. Idle tabs // shouldn't be hitting the DB every 15s. const inFlightPosts = (posts ?? []).filter( - (p: any) => p.status === "queued" || p.status === "publishing", + (p: any) => + p.status === "queued" || + p.status === "publishing" || + p.status === "awaiting_code", ); const hasInFlightPost = inFlightPosts.length > 0; const feedChecking = @@ -484,7 +488,7 @@ export default async function SocialDashboardPage({ : "badge-warn") } > - {p.status} + {p.status === "awaiting_code" ? "needs code" : p.status} {url && title && ( @@ -531,6 +535,14 @@ export default async function SocialDashboardPage({ )} + {p.status === "awaiting_code" && ( +
+ +
+ )} {p.status === "failed" && (
{p.last_error && ( diff --git a/app/(marketing)/recent/outreach-form.tsx b/app/(marketing)/recent/outreach-form.tsx index 4c2c70d0..8565e572 100644 --- a/app/(marketing)/recent/outreach-form.tsx +++ b/app/(marketing)/recent/outreach-form.tsx @@ -8,16 +8,19 @@ import { sendRecentAuditOutreach, } from "@/app/actions/recent-outreach"; import { OUTREACH_CREDITS } from "@/lib/credits"; +import { VerificationCodeInput } from "@/components/verification-code-input"; export type OutreachHistoryItem = { id: string; channel: string; provider: string; - status: "sent" | "failed" | "queued" | "timed_out"; + status: "sent" | "failed" | "queued" | "timed_out" | "awaiting_code"; subject: string | null; error: string | null; createdAt: string; url: string | null; + verificationPostId?: string | null; + verificationPrompt?: string | null; }; export function RecentOutreachForm({ @@ -132,6 +135,14 @@ export function RecentOutreachForm({ {(h.status === "failed" || h.status === "timed_out") && ( )} + {h.status === "awaiting_code" && h.verificationPostId && ( + + + + )} {(h.status === "failed" || h.status === "timed_out") && h.error && ( {h.error} @@ -325,7 +336,9 @@ function defaultBody(host: string) { } function statusLabel(status: OutreachHistoryItem["status"]) { - return status === "timed_out" ? "timed out" : status; + if (status === "timed_out") return "timed out"; + if (status === "awaiting_code") return "needs code"; + return status; } function statusClass(status: OutreachHistoryItem["status"]) { @@ -333,5 +346,6 @@ function statusClass(status: OutreachHistoryItem["status"]) { if (status === "sent") return `${base} bg-green-100 text-green-800`; if (status === "failed") return `${base} bg-red-100 text-red-700`; if (status === "timed_out") return `${base} bg-amber-100 text-amber-800`; + if (status === "awaiting_code") return `${base} bg-blue-100 text-blue-800`; return `${base} bg-[var(--color-border)] text-[var(--color-muted)]`; } diff --git a/app/(marketing)/recent/page.tsx b/app/(marketing)/recent/page.tsx index a895cc08..1b81db5b 100644 --- a/app/(marketing)/recent/page.tsx +++ b/app/(marketing)/recent/page.tsx @@ -321,7 +321,7 @@ async function fetchOutreachHistory( const { data } = await supabase .from("recent_outreach_messages") .select( - "id, audit_id, channel, provider, status, subject, error, created_at, social_post:sp_post(status, platform_post_url, last_error)", + "id, audit_id, channel, provider, status, subject, error, created_at, social_post:sp_post(id, status, platform_post_url, last_error, verification_prompt)", ) .eq("organization_id", organizationId) .in("audit_id", auditIds) @@ -354,6 +354,10 @@ async function fetchOutreachHistory( error: derived.error, createdAt: row.created_at, url: post?.platform_post_url ?? null, + verificationPostId: + derived.status === "awaiting_code" ? post?.id ?? null : null, + verificationPrompt: + derived.status === "awaiting_code" ? post?.verification_prompt ?? null : null, }; const list = byAudit.get(row.audit_id) ?? []; list.push(item); diff --git a/app/actions/socialPosting.ts b/app/actions/socialPosting.ts index 81bced2b..e34c7e1b 100644 --- a/app/actions/socialPosting.ts +++ b/app/actions/socialPosting.ts @@ -918,3 +918,50 @@ export async function retryFeedItem(input: { if (projectId) revalidatePath(`/projects/${projectId}/social`); return { ok: true }; } + +// ------------------------------------------------------------ +// submitVerificationCode — hand a verification code to a browser post that is +// paused on an identity challenge (status 'awaiting_code'). The worker is +// holding the live Chromium session open and polling sp_post.verification_code; +// writing it here lets it type the code and finish posting. +// ------------------------------------------------------------ +export async function submitVerificationCode(input: { + postId: string; + code: string; +}): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Not authenticated." }; + + // Accept the common 4–8 digit codes; strip spaces/dashes the user may paste. + const code = input.code.replace(/[\s-]/g, ""); + if (!/^\d{4,8}$/.test(code)) { + return { ok: false, error: "Enter the numeric code (4–8 digits)." }; + } + + const { data: post } = await supabase + .from("sp_post") + .select("id, status, project_id") + .eq("id", input.postId) + .eq("user_id", user.id) + .maybeSingle(); + if (!post) return { ok: false, error: "Post not found." }; + if ((post as { status?: string }).status !== "awaiting_code") { + return { ok: false, error: "This post isn't waiting for a code right now." }; + } + + const { error } = await supabase + .from("sp_post") + .update({ verification_code: code }) + .eq("id", input.postId) + .eq("user_id", user.id) + .eq("status", "awaiting_code"); + if (error) return { ok: false, error: error.message }; + + const projectId = (post as { project_id?: string | null }).project_id; + if (projectId) revalidatePath(`/projects/${projectId}/social`); + revalidatePath("/recent"); + return { ok: true }; +} diff --git a/components/verification-code-input.tsx b/components/verification-code-input.tsx new file mode 100644 index 00000000..998c4f0d --- /dev/null +++ b/components/verification-code-input.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { submitVerificationCode } from "@/app/actions/socialPosting"; + +// Shown on a browser post that's paused on an identity challenge +// (status 'awaiting_code'). The worker is holding the live session open and +// polling for the code; submitting it here lets the post finish. +export function VerificationCodeInput({ + postId, + prompt, +}: { + postId: string; + prompt?: string | null; +}) { + const router = useRouter(); + const [code, setCode] = useState(""); + const [pending, start] = useTransition(); + const [err, setErr] = useState(null); + const [done, setDone] = useState(false); + + if (done) { + return ( + + Code submitted — finishing the post… + + ); + } + + return ( + + {prompt && ( + {prompt} + )} + setCode(e.target.value)} + inputMode="numeric" + autoComplete="one-time-code" + placeholder="123456" + className="w-24 rounded border border-[var(--color-border)] bg-[var(--color-card)] px-2 py-0.5 tabular-nums" + /> + + {err && ( + + {err} + + )} + + ); +} diff --git a/lib/sp/browserPost.ts b/lib/sp/browserPost.ts index dc2c0d65..5c45bc50 100644 --- a/lib/sp/browserPost.ts +++ b/lib/sp/browserPost.ts @@ -31,6 +31,7 @@ import { } from "@/lib/sp/imageGen"; import { reconcileOutreach } from "@/lib/sp/outreachReconcile"; import { pickDefaultSubreddit } from "@/lib/sp/redditSubreddit"; +import { makeCodeWaiter } from "@/lib/sp/verificationChallenge"; export async function processBrowserPost(args: { postId: string; @@ -108,6 +109,11 @@ export async function processBrowserPost(args: { return; } + // When a platform interrupts the session for a verification code, the + // platform flow calls this to pause, surface a prompt, and wait for the code + // the user submits (see makeCodeWaiter / submitVerificationCode). + const waitForCode = makeCodeWaiter(supabase, postId); + try { let result: { platformPostId: string; webUrl: string }; @@ -118,29 +124,31 @@ export async function processBrowserPost(args: { const subreddit = ((claimed.subreddit as string | null) ?? "").trim() || pickDefaultSubreddit(title || text); - result = await redditBrowserPost({ cookies, subreddit, title, text }); + result = await redditBrowserPost({ cookies, subreddit, title, text, waitForCode }); } else if (account.platform === "facebook_page") { result = await facebookBrowserPost({ cookies, pageId: account.external_id, text, imageUrl, + waitForCode, }); } else if (account.platform === "threads") { - result = await threadsBrowserPost({ cookies, text, imageUrl }); + result = await threadsBrowserPost({ cookies, text, imageUrl, waitForCode }); } else if (account.platform === "instagram") { result = await instagramBrowserPost({ cookies, caption: text, imageUrl: imageUrl!, + waitForCode, }); } else if (account.platform === "x") { - result = await xBrowserPost({ cookies, text, imageUrl }); + result = await xBrowserPost({ cookies, text, imageUrl, waitForCode }); } else if (account.platform === "linkedin") { - result = await linkedinBrowserPost({ cookies, text, imageUrl }); + result = await linkedinBrowserPost({ cookies, text, imageUrl, waitForCode }); } else if (account.platform === "mastodon") { const instanceUrl = account.instance_url ?? "mastodon.social"; - result = await mastodonBrowserPost({ cookies, instanceUrl, text, imageUrl }); + result = await mastodonBrowserPost({ cookies, instanceUrl, text, imageUrl, waitForCode }); } else { throw new Error(`Browser posting not implemented for platform: ${account.platform}`); } diff --git a/lib/sp/outreachStatus.ts b/lib/sp/outreachStatus.ts index 1a094cd3..6d3937d1 100644 --- a/lib/sp/outreachStatus.ts +++ b/lib/sp/outreachStatus.ts @@ -8,12 +8,19 @@ // never came back. export type OutreachRowStatus = "sent" | "failed" | "queued"; -export type OutreachDisplayStatus = "sent" | "failed" | "queued" | "timed_out"; +export type OutreachDisplayStatus = + | "sent" + | "failed" + | "queued" + | "timed_out" + | "awaiting_code"; export type OutreachSocialPost = { + id?: string | null; status: string | null; platform_post_url: string | null; last_error: string | null; + verification_prompt?: string | null; }; // How long a browser-automated post (auth_mode='cookie') may sit in the @@ -37,6 +44,14 @@ export function deriveOutreachStatus( if (post.status === "failed" || post.status === "cancelled") { return { status: "failed", error: post.last_error ?? rowError }; } + // Paused on an identity challenge — the user needs to enter a code. Surface + // it before the stale check so it isn't mistaken for a hung job. + if (post.status === "awaiting_code") { + return { + status: "awaiting_code", + error: post.verification_prompt ?? "Waiting for a verification code.", + }; + } // Still queued_browser / publishing — flip to timed out once stale. if (now - new Date(createdAt).getTime() > OUTREACH_STALE_MS) { return { diff --git a/lib/sp/platforms/browser.ts b/lib/sp/platforms/browser.ts index eb3914ff..c732b376 100644 --- a/lib/sp/platforms/browser.ts +++ b/lib/sp/platforms/browser.ts @@ -16,6 +16,13 @@ import { chromium, type Browser, type BrowserContext } from "playwright"; import { browserSemaphore } from "@/lib/sp/browserSemaphore"; +import { handleCodeChallenge, type CodeWaiter } from "@/lib/sp/verificationChallenge"; + +// Standard challenge prompt shown to the user when a platform interrupts the +// session asking for a verification code. +function codePrompt(platform: string): string { + return `${platform} is asking for a verification code. Enter the code it just sent (email / SMS / authenticator) to finish posting.`; +} export type BrowserCookie = { name: string; @@ -115,8 +122,9 @@ export async function redditBrowserPost(args: { subreddit: string; title: string; text: string; + waitForCode?: CodeWaiter; }): Promise { - const { cookies, subreddit, title, text } = args; + const { cookies, subreddit, title, text, waitForCode } = args; const sr = subreddit.replace(/^\/?r\//, ""); const { browser, ctx } = await launchContext(cookies); try { @@ -124,6 +132,7 @@ export async function redditBrowserPost(args: { await page.goto(`https://www.reddit.com/r/${sr}/submit`, { waitUntil: "domcontentloaded", }); + if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Reddit")); // Select "Text" tab const textTab = page.getByRole("tab", { name: /text/i }); @@ -164,14 +173,16 @@ export async function facebookBrowserPost(args: { pageId: string; text: string; imageUrl?: string; + waitForCode?: CodeWaiter; }): Promise { - const { cookies, pageId, text, imageUrl } = args; + const { cookies, pageId, text, imageUrl, waitForCode } = args; const { browser, ctx } = await launchContext(cookies); try { const page = await ctx.newPage(); await page.goto(`https://www.facebook.com/${pageId}`, { waitUntil: "domcontentloaded", }); + if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Facebook")); // Click the "Write something..." composer const composer = page.getByPlaceholder(/write something/i) @@ -227,12 +238,14 @@ export async function threadsBrowserPost(args: { cookies: BrowserCookie[]; text: string; imageUrl?: string; + waitForCode?: CodeWaiter; }): Promise { - const { cookies, text, imageUrl } = args; + const { cookies, text, imageUrl, waitForCode } = args; const { browser, ctx } = await launchContext(cookies); try { const page = await ctx.newPage(); await page.goto("https://www.threads.net", { waitUntil: "domcontentloaded" }); + if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Threads")); // New Thread button const newThreadBtn = page @@ -287,12 +300,14 @@ export async function instagramBrowserPost(args: { cookies: BrowserCookie[]; caption: string; imageUrl: string; // required — Instagram does not support text-only posts + waitForCode?: CodeWaiter; }): Promise { - const { cookies, caption, imageUrl } = args; + const { cookies, caption, imageUrl, waitForCode } = args; const { browser, ctx } = await launchContext(cookies); try { const page = await ctx.newPage(); await page.goto("https://www.instagram.com", { waitUntil: "domcontentloaded" }); + if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Instagram")); // Download image to temp file const imgRes = await fetch(imageUrl); @@ -354,12 +369,14 @@ export async function xBrowserPost(args: { cookies: BrowserCookie[]; text: string; imageUrl?: string; + waitForCode?: CodeWaiter; }): Promise { - const { cookies, text, imageUrl } = args; + const { cookies, text, imageUrl, waitForCode } = args; const { browser, ctx } = await launchContext(cookies); try { const page = await ctx.newPage(); await page.goto("https://x.com/home", { waitUntil: "domcontentloaded" }); + if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("X")); // Click the compose box const compose = page @@ -410,12 +427,14 @@ export async function linkedinBrowserPost(args: { cookies: BrowserCookie[]; text: string; imageUrl?: string; + waitForCode?: CodeWaiter; }): Promise { - const { cookies, text, imageUrl } = args; + const { cookies, text, imageUrl, waitForCode } = args; const { browser, ctx } = await launchContext(cookies); try { const page = await ctx.newPage(); await page.goto("https://www.linkedin.com/feed/", { waitUntil: "domcontentloaded" }); + if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("LinkedIn")); // Start a post const startPost = page @@ -476,19 +495,24 @@ export async function mastodonBrowserPost(args: { instanceUrl: string; text: string; imageUrl?: string; + waitForCode?: CodeWaiter; }): Promise { - const { cookies, instanceUrl, text, imageUrl } = args; + const { cookies, instanceUrl, text, imageUrl, waitForCode } = args; const base = instanceUrl.startsWith("http") ? instanceUrl : `https://${instanceUrl}`; const { browser, ctx } = await launchContext(cookies); try { const page = await ctx.newPage(); await page.goto(base, { waitUntil: "domcontentloaded" }); + if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Mastodon")); - // Mastodon web app — compose textarea + // Mastodon web app — compose textarea. Match the compose box specifically: + // a bare getByRole("textbox") also matched the "Search or paste URL" input + // and tripped strict-mode. The compose box's aria-label is "What's on your + // mind?" and it carries the autosuggest-textarea__textarea class. const compose = page - .locator('textarea.autosuggest-textarea__textarea') - .or(page.locator('[placeholder*="what" i]').first()) - .or(page.getByRole("textbox").first()); + .locator("textarea.autosuggest-textarea__textarea") + .or(page.getByRole("textbox", { name: /what.?s on your mind/i })) + .first(); await compose.waitFor({ timeout: 10_000 }); await compose.fill(text); diff --git a/lib/sp/verificationChallenge.ts b/lib/sp/verificationChallenge.ts new file mode 100644 index 00000000..9acea4b6 --- /dev/null +++ b/lib/sp/verificationChallenge.ts @@ -0,0 +1,132 @@ +// Human-in-the-loop verification-code (identity challenge) handling for +// browser-automated posts. +// +// Some platforms (LinkedIn especially) interrupt a cookie session with an +// "enter the 6-digit code we just emailed/texted you" challenge before showing +// the composer. We keep the SAME Playwright session open, mark the post +// 'awaiting_code' with a human-readable prompt, and poll sp_post for a code the +// user submits in the UI. When it arrives we type it into the live page and +// continue posting. If no code shows up within the timeout we give up so the +// browser (and its concurrency slot) is freed. + +import type { Locator, Page } from "playwright"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +// A function a platform flow calls when it detects a challenge: it surfaces the +// prompt to the user and resolves with the code they enter (or throws on +// timeout). +export type CodeWaiter = (prompt: string) => Promise; + +const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000; // 3 min — a browser slot is held open. +const DEFAULT_POLL_MS = 3 * 1000; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// Build a CodeWaiter bound to one post. Marks the post 'awaiting_code' (storing +// the prompt so the UI can show what's being asked), then polls until the user +// submits a code or we time out. On success it flips back to 'publishing' and +// clears the code so it can't be replayed. +export function makeCodeWaiter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + supabase: SupabaseClient, + postId: string, + opts: { timeoutMs?: number; pollMs?: number } = {}, +): CodeWaiter { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const pollMs = opts.pollMs ?? DEFAULT_POLL_MS; + + return async function waitForCode(prompt: string): Promise { + await supabase + .from("sp_post") + .update({ + status: "awaiting_code", + verification_prompt: prompt, + verification_requested_at: new Date().toISOString(), + verification_code: null, + }) + .eq("id", postId); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep(pollMs); + const { data } = await supabase + .from("sp_post") + .select("verification_code") + .eq("id", postId) + .maybeSingle(); + const code = ((data?.verification_code as string | null) ?? "").trim(); + if (code) { + await supabase + .from("sp_post") + .update({ + status: "publishing", + verification_code: null, + verification_prompt: null, + }) + .eq("id", postId); + return code; + } + } + + throw new Error( + "Timed out waiting for the verification code. Enter it sooner and retry.", + ); + }; +} + +// Best-effort detector for a verification-code input on the current page. +// Deliberately narrow (one-time-code / pin / otp / verification hints) to avoid +// mistaking a normal text field for a challenge. Returns the input + a likely +// submit control, or null when no challenge is visible. +export async function detectCodeChallenge( + page: Page, + timeoutMs = 2500, +): Promise<{ input: Locator; submit: Locator | null } | null> { + const input = page + .locator('input[autocomplete="one-time-code"]') + .or(page.locator('input[name*="pin" i]')) + .or(page.locator('input[id*="pin" i]')) + .or(page.locator('input[name*="otp" i]')) + .or(page.locator('input[name*="verification" i]')) + .or(page.locator('input[id*="verification" i]')) + .or(page.locator('input[aria-label*="verification code" i]')) + .or(page.locator('input[name="code" i]')) + .first(); + + const visible = await input + .waitFor({ state: "visible", timeout: timeoutMs }) + .then(() => true) + .catch(() => false); + if (!visible) return null; + + const submit = page + .getByRole("button", { name: /verify|submit|confirm|continue|next|done/i }) + .first(); + const hasSubmit = await submit.isVisible({ timeout: 1000 }).catch(() => false); + return { input, submit: hasSubmit ? submit : null }; +} + +// If a code challenge is on the page, resolve it end-to-end: ask the user (via +// waitForCode), type the code, submit, and wait for the page to move on. +// Returns true if a challenge was handled, false if none was present. Safe to +// call at the top of every platform flow. +export async function handleCodeChallenge( + page: Page, + waitForCode: CodeWaiter, + prompt: string, +): Promise { + const challenge = await detectCodeChallenge(page); + if (!challenge) return false; + + const code = await waitForCode(prompt); + await challenge.input.fill(code); + if (challenge.submit) { + await challenge.submit.click().catch(() => {}); + } else { + await page.keyboard.press("Enter").catch(() => {}); + } + // Let the challenge clear and the real app render. + await page.waitForLoadState("domcontentloaded").catch(() => {}); + await page.waitForTimeout(2000); + return true; +} diff --git a/supabase/migrations/20260704140000_sp_post_verification.sql b/supabase/migrations/20260704140000_sp_post_verification.sql new file mode 100644 index 00000000..91ac559d --- /dev/null +++ b/supabase/migrations/20260704140000_sp_post_verification.sql @@ -0,0 +1,14 @@ +-- Verification-code (identity challenge) support for browser-automated posts. +-- +-- Platforms like LinkedIn interrupt a cookie session with "enter the 6-digit +-- code we just sent you". The worker keeps that Chromium session open, flips +-- the post to status 'awaiting_code', and polls sp_post.verification_code for a +-- code the user types into the UI (submitVerificationCode). Once it arrives the +-- worker fills it in the live page and finishes posting. +-- +-- status is a free-text column, so 'awaiting_code' needs no enum change. + +alter table public.sp_post + add column if not exists verification_code text, + add column if not exists verification_prompt text, + add column if not exists verification_requested_at timestamptz; diff --git a/tests/sp/verification-challenge.test.ts b/tests/sp/verification-challenge.test.ts new file mode 100644 index 00000000..16d1a40e --- /dev/null +++ b/tests/sp/verification-challenge.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { makeCodeWaiter } from "@/lib/sp/verificationChallenge"; + +// In-memory stand-in for the single sp_post row the waiter reads/writes. +function fakeSb(initial: Record = {}) { + const row: Record = { id: "post-1", status: "publishing", ...initial }; + const updates: Record[] = []; + const sb = { + from() { + return { + update(payload: Record) { + updates.push(payload); + Object.assign(row, payload); + return { eq() { return Promise.resolve({ data: null, error: null }); } }; + }, + select() { + return { + eq() { + return { + maybeSingle: async () => ({ + data: { verification_code: row.verification_code ?? null }, + error: null, + }), + }; + }, + }; + }, + }; + }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { sb: sb as any, row, updates }; +} + +describe("makeCodeWaiter", () => { + it("marks the post awaiting_code, returns the submitted code, and clears it", async () => { + const { sb, row, updates } = fakeSb(); + const waiter = makeCodeWaiter(sb, "post-1", { timeoutMs: 2000, pollMs: 20 }); + + const p = waiter("LinkedIn needs a code"); + // Simulate the user submitting a code shortly after the prompt appears. + setTimeout(() => { + row.verification_code = "437748"; + }, 50); + + await expect(p).resolves.toBe("437748"); + + // First update flips to awaiting_code with the prompt. + expect(updates[0]).toMatchObject({ + status: "awaiting_code", + verification_prompt: "LinkedIn needs a code", + verification_code: null, + }); + // Final state: back to publishing, code consumed so it can't replay. + expect(row.status).toBe("publishing"); + expect(row.verification_code).toBeNull(); + expect(row.verification_prompt).toBeNull(); + }); + + it("throws on timeout when no code is submitted", async () => { + const { sb } = fakeSb(); + const waiter = makeCodeWaiter(sb, "post-1", { timeoutMs: 60, pollMs: 20 }); + await expect(waiter("prompt")).rejects.toThrow(/timed out/i); + }); + + it("trims whitespace around a submitted code", async () => { + const { sb, row } = fakeSb(); + const waiter = makeCodeWaiter(sb, "post-1", { timeoutMs: 2000, pollMs: 20 }); + const p = waiter("prompt"); + setTimeout(() => { + row.verification_code = " 123456 "; + }, 40); + await expect(p).resolves.toBe("123456"); + }); +});