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
14 changes: 12 additions & 2 deletions lib/lx/autoGuestPost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,19 @@ export async function isGuestPostSlot(
supabase: SupabaseClient<any>,
authorSiteId: string,
): Promise<boolean> {
// 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
Expand Down Expand Up @@ -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);

Expand Down
54 changes: 40 additions & 14 deletions tests/contract/auto-guest-post.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,36 +22,43 @@ 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") {
return {
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,
})),
}),
});
},
}),
}),
};
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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.
Expand Down