diff --git a/lib/lx/autoGuestPost.ts b/lib/lx/autoGuestPost.ts index d8c1058..68312a5 100644 --- a/lib/lx/autoGuestPost.ts +++ b/lib/lx/autoGuestPost.ts @@ -68,10 +68,19 @@ export async function isGuestPostSlot( supabase: SupabaseClient, authorSiteId: string, ): Promise { + // A guest post is stored under the PARTNER's site_id, with the author in + // author_site_id. Counting by site_id alone missed every guest post this site + // wrote, so once its own count sat one short of a multiple of ten the slot + // stayed a guest slot for ever: pairux.com published nothing to its own blog + // from 2026-08-19 while writing the same guest post daily. Count what this + // site authored — its own posts plus the guest posts it wrote elsewhere — + // and not the guest posts partners wrote onto it. const { count, error } = await supabase .from("lx_article") .select("id", { count: "exact", head: true }) - .eq("site_id", authorSiteId); + .or( + `and(site_id.eq.${authorSiteId},is_guest_post.eq.false),author_site_id.eq.${authorSiteId}`, + ); // A failed count must not silently turn every slot into a guest post, nor // silently turn none of them into one. Falling back to "no" keeps the @@ -114,10 +123,11 @@ export async function planGuestPost( // Who we have written for lately. One query for the whole cooldown rather // than one per candidate: the list is short and the cron is walking every // active site. + // By author_site_id, not site_id: the article lives under the partner. const { data: recent } = await supabase .from("lx_article") .select("target_site_id") - .eq("site_id", authorSiteId) + .eq("author_site_id", authorSiteId) .eq("is_guest_post", true) .gte("created_at", since); diff --git a/tests/contract/auto-guest-post.test.ts b/tests/contract/auto-guest-post.test.ts index 39c1cb7..0a0cfac 100644 --- a/tests/contract/auto-guest-post.test.ts +++ b/tests/contract/auto-guest-post.test.ts @@ -22,12 +22,15 @@ const matcher = vi.mocked(findGuestPostOpportunities); * getting one of those wrong is precisely the kind of mistake that returns * `undefined` and silently disables the feature rather than failing. */ -function stubSupabase(opts: { - articleCount?: number; - countError?: string; - recentGuestTargets?: string[]; - existingRequests?: Array<{ target_site_id: string; topic: string }>; -}) { +function stubSupabase( + opts: { + articleCount?: number; + countError?: string; + recentGuestTargets?: string[]; + existingRequests?: Array<{ target_site_id: string; topic: string }>; + }, + filters: string[] = [], +) { return { from(table: string) { if (table === "lx_article") { @@ -35,23 +38,27 @@ function stubSupabase(opts: { select(_cols: string, o?: { count?: string; head?: boolean }) { if (o?.head) { return { - eq: () => - Promise.resolve( + or: (f: string) => { + filters.push(`or:${f}`); + return Promise.resolve( opts.countError ? { count: null, error: { message: opts.countError } } : { count: opts.articleCount ?? 0, error: null }, - ), + ); + }, }; } return { - eq: () => ({ - eq: () => ({ - gte: () => - Promise.resolve({ + eq: (col: string, val: unknown) => ({ + eq: (col2: string, val2: unknown) => ({ + gte: () => { + filters.push(`eq:${col}=${String(val)}`, `eq:${col2}=${String(val2)}`); + return Promise.resolve({ data: (opts.recentGuestTargets ?? []).map((id) => ({ target_site_id: id, })), - }), + }); + }, }), }), }; @@ -107,6 +114,16 @@ describe("guest post cadence", () => { ); expect(slot).toBe(false); }); + + it("counts the guest posts this site wrote, which live under the partner", async () => { + // Counting by site_id alone never saw them, so a site one short of a + // multiple of ten was handed a guest slot on every tick, for ever. + const filters: string[] = []; + await isGuestPostSlot(stubSupabase({}, filters), "author"); + expect(filters).toEqual([ + "or:and(site_id.eq.author,is_guest_post.eq.false),author_site_id.eq.author", + ]); + }); }); describe("choosing a partner", () => { @@ -142,6 +159,15 @@ describe("choosing a partner", () => { expect(plan?.targetSiteId).toBe("fresh"); }); + it("looks up the cooldown by author, not by the site the post lives on", async () => { + // The same mistake as the cadence count: by site_id the cooldown found + // nothing, and one partner got the same post seventeen days running. + matcher.mockResolvedValue([opportunity("partner", ["topic a"])]); + const filters: string[] = []; + await planGuestPost(stubSupabase({}, filters), "author"); + expect(filters).toEqual(["eq:author_site_id=author", "eq:is_guest_post=true"]); + }); + it("does not commission a topic a human already requested", async () => { // The manual path and the cron share one ledger so they cannot // independently order the same article.