diff --git a/lib/promote/blend.ts b/lib/promote/blend.ts index 76af6de..6f16c8e 100644 --- a/lib/promote/blend.ts +++ b/lib/promote/blend.ts @@ -95,11 +95,45 @@ export type BlendInput = { posted: Partial>; /** Which classes have a link ready to post right now. */ available: Partial>; + /** + * Which classes the campaign has an enabled source feeding. A class with no + * inventory *and* no source is not starved — the campaign simply does not + * publish that kind of content. + */ + hasSource?: Partial>; fallback: FallbackPolicy; /** Fallback posts already made today, against maxFallbackItemsPerDay. */ fallbackUsedToday?: number; }; +/** + * Narrow a mix to the classes this campaign can actually supply. + * + * Without this, a campaign of nothing but the user's own pasted links reads its + * 70/30 default as "30% short on shared content", finds none, and posts its own + * links as *fallbacks* — every tick, until maxFallbackItemsPerDay stops the + * campaign dead. A class the campaign has no inventory and no source for is not + * a starved class; it is not part of this campaign's mix at all. + * + * Returns the original mix when nothing qualifies, so the caller still gets a + * ranking to reason about rather than an empty one. + */ +export function effectiveMix( + mix: BlendMix, + available: Partial>, + hasSource: Partial> = {}, +): BlendMix { + const restricted: BlendMix = { owned: 0, partner: 0, shared: 0 }; + let total = 0; + for (const key of OWNERSHIPS) { + if (mix[key] <= 0) continue; + if (!available[key] && !hasSource[key]) continue; + restricted[key] = mix[key]; + total += mix[key]; + } + return total > 0 ? restricted : mix; +} + /** * Rank the ownership classes by how far each is below its target share. * Exported for the preview UI, which shows why a given item is next. @@ -124,7 +158,8 @@ export function rankByDeficit(mix: BlendMix, posted: Partial> = {}; + for (const row of (sourceRows ?? []) as Array<{ ownership: string }>) { + const key = row.ownership as Ownership; + if (OWNERSHIPS.includes(key)) hasSource[key] = true; + } + const fallbackUsedToday = await countFallbackToday(supabase, list.id, now); const decision = chooseOwnership({ mix, posted, available, + hasSource, fallback, fallbackUsedToday, }); diff --git a/tests/promote/blend.test.ts b/tests/promote/blend.test.ts index d784701..8b38393 100644 --- a/tests/promote/blend.test.ts +++ b/tests/promote/blend.test.ts @@ -4,6 +4,7 @@ import { parseFallback, parseMix, rankByDeficit, + effectiveMix, DEFAULT_MIX, type BlendMix, type FallbackPolicy, @@ -102,9 +103,19 @@ describe("chooseOwnership", () => { describe("when the owned queue is empty", () => { const available = { owned: false, shared: true }; + // The campaign has an owned source; it just has nothing to post from it + // right now. That is what makes owned genuinely starved, rather than + // simply not part of this campaign's mix. + const hasSource = { owned: true, shared: true }; it("uses shared content by default, flagged as a fallback", () => { - const decision = chooseOwnership({ mix, posted: {}, available, fallback: permissive }); + const decision = chooseOwnership({ + mix, + posted: {}, + available, + hasSource, + fallback: permissive, + }); expect(decision.ownership).toBe("shared"); expect(decision.viaFallback).toBe(true); expect(decision.reason).toBe("fallback"); @@ -115,6 +126,7 @@ describe("chooseOwnership", () => { mix, posted: {}, available, + hasSource, fallback: { ...permissive, whenOwnedQueueEmpty: "pause" }, }); expect(decision.ownership).toBeNull(); @@ -124,13 +136,14 @@ describe("chooseOwnership", () => { it("stops once the daily fallback cap is reached", () => { const fallback = { ...permissive, maxFallbackItemsPerDay: 3 }; expect( - chooseOwnership({ mix, posted: {}, available, fallback, fallbackUsedToday: 2 }) + chooseOwnership({ mix, posted: {}, available, hasSource, fallback, fallbackUsedToday: 2 }) .ownership, ).toBe("shared"); const capped = chooseOwnership({ mix, posted: {}, available, + hasSource, fallback, fallbackUsedToday: 3, }); @@ -156,6 +169,7 @@ describe("chooseOwnership", () => { mix, posted: { owned: 9, shared: 0 }, available: { owned: true, shared: false }, + hasSource: { shared: true }, fallback: permissive, }); expect(decision.ownership).toBe("owned"); @@ -164,15 +178,22 @@ describe("chooseOwnership", () => { it("reaches an unweighted class only under use_any_available", () => { const onlyPartner = { owned: false, shared: false, partner: true }; + const hasSource = { owned: true, shared: true }; expect( - chooseOwnership({ mix, posted: {}, available: onlyPartner, fallback: permissive }) - .ownership, + chooseOwnership({ + mix, + posted: {}, + available: onlyPartner, + hasSource, + fallback: permissive, + }).ownership, ).toBeNull(); expect( chooseOwnership({ mix, posted: {}, available: onlyPartner, + hasSource, fallback: { ...permissive, whenOwnedQueueEmpty: "use_any_available" }, }).ownership, ).toBe("partner"); @@ -190,6 +211,95 @@ describe("chooseOwnership", () => { }); }); +describe("a campaign that only has its own content", () => { + // Regression, found in production on 2026-08-18. A list of 39 hand-pasted + // owned links carried the 70/30 default mix. The blend read it as "30% short + // on shared content", found none, and posted the user's own links as + // *fallbacks* — six in one tick. maxFallbackItemsPerDay is 3, so the next + // tick would have posted nothing and the campaign would have looked dead. + const mix: BlendMix = { owned: 70, partner: 0, shared: 30 }; + const onlyOwned = { owned: true }; + + it("posts its own content on target, not as a fallback", () => { + const decision = chooseOwnership({ + mix, + posted: { owned: 50 }, + available: onlyOwned, + fallback: { ...permissive, maxFallbackItemsPerDay: 3 }, + }); + expect(decision.ownership).toBe("owned"); + expect(decision.viaFallback).toBe(false); + expect(decision.reason).toBe("on_target"); + }); + + it("keeps posting indefinitely, never tripping the fallback cap", () => { + const posted: Partial> = {}; + for (let i = 0; i < 40; i++) { + const decision = chooseOwnership({ + mix, + posted, + available: onlyOwned, + fallback: { ...permissive, maxFallbackItemsPerDay: 3 }, + // Every previous post was on-target, so nothing accumulates here. + fallbackUsedToday: 0, + }); + expect(decision.ownership).toBe("owned"); + expect(decision.viaFallback).toBe(false); + posted.owned = (posted.owned ?? 0) + 1; + } + }); + + it("does start honouring the ratio once a shared source exists", () => { + // Same campaign, now subscribed to a keyword source. Shared is genuinely + // starved, so covering for it IS a fallback and the cap should apply. + const decision = chooseOwnership({ + mix, + posted: { owned: 50 }, + available: onlyOwned, + hasSource: { shared: true }, + fallback: { ...permissive, maxFallbackItemsPerDay: 3 }, + }); + expect(decision.ownership).toBe("owned"); + expect(decision.viaFallback).toBe(true); + }); + + it("prefers the shared source's content as soon as it has any", () => { + const decision = chooseOwnership({ + mix, + posted: { owned: 50 }, + available: { owned: true, shared: true }, + hasSource: { shared: true }, + fallback: permissive, + }); + expect(decision.ownership).toBe("shared"); + expect(decision.viaFallback).toBe(false); + }); +}); + +describe("effectiveMix", () => { + const mix: BlendMix = { owned: 70, partner: 0, shared: 30 }; + + it("drops a class the campaign can neither stock nor fetch", () => { + expect(effectiveMix(mix, { owned: true }, {})).toEqual({ + owned: 70, + partner: 0, + shared: 0, + }); + }); + + it("keeps a class that has a source but no inventory right now", () => { + expect(effectiveMix(mix, { owned: true }, { shared: true })).toEqual(mix); + }); + + it("keeps a class that has inventory but no source", () => { + expect(effectiveMix(mix, { owned: true, shared: true }, {})).toEqual(mix); + }); + + it("returns the original mix when nothing qualifies", () => { + expect(effectiveMix(mix, {}, {})).toEqual(mix); + }); +}); + describe("rankByDeficit", () => { it("puts the class furthest below its target first", () => { const mix: BlendMix = { owned: 70, partner: 0, shared: 30 }; diff --git a/tests/promote/select-link.test.ts b/tests/promote/select-link.test.ts index 0ebd369..f433641 100644 --- a/tests/promote/select-link.test.ts +++ b/tests/promote/select-link.test.ts @@ -23,9 +23,16 @@ function link(over: Record = {}) { } function db(over: Partial = {}): FakeDb { - return { promo_link: [], promo_post: [], ...over }; + return { promo_link: [], promo_post: [], promo_source: [], ...over }; } +// A campaign only treats a class as *starved* if something is meant to be +// feeding it. Without a source, an empty class is simply not part of the mix — +// which is what stops an all-owned campaign posting its own links as fallbacks. +const sourceFor = (ownership: string) => [ + { id: `src-${ownership}`, list_id: "list-a", ownership, enabled: true }, +]; + const list = { id: "list-a", source_mix: { owned: 70, partner: 0, shared: 30 }, @@ -102,6 +109,7 @@ describe("selectNextLink", () => { link({ id: "off", ownership: "owned", enabled: false }), link({ id: "on", ownership: "shared" }), ], + promo_source: sourceFor("owned"), }), ); const selection = await selectNextLink(client, list, NOW); @@ -111,7 +119,10 @@ describe("selectNextLink", () => { it("falls back to shared when the owned queue is empty", async () => { const { client } = makeFakeSupabase( - db({ promo_link: [link({ id: "shared-1", ownership: "shared" })] }), + db({ + promo_link: [link({ id: "shared-1", ownership: "shared" })], + promo_source: sourceFor("owned"), + }), ); const selection = await selectNextLink(client, list, NOW); expect(selection.link?.id).toBe("shared-1"); @@ -123,6 +134,7 @@ describe("selectNextLink", () => { const { client } = makeFakeSupabase( db({ promo_link: [link({ id: "shared-1", ownership: "shared" })], + promo_source: sourceFor("owned"), promo_post: Array.from({ length: 3 }, (_, i) => ({ id: `fb-${i}`, list_id: "list-a", @@ -142,6 +154,7 @@ describe("selectNextLink", () => { const { client } = makeFakeSupabase( db({ promo_link: [link({ id: "shared-1", ownership: "shared" })], + promo_source: sourceFor("owned"), promo_post: Array.from({ length: 3 }, (_, i) => ({ id: `fb-${i}`, list_id: "list-a", @@ -201,6 +214,31 @@ describe("selectNextLink", () => { expect((await selectNextLink(client, list, NOW)).link?.id).toBe("shared-1"); }); + it("does not treat an all-owned campaign's posts as fallbacks", async () => { + // Production regression, 2026-08-18. The "lives" campaign: 39 hand-pasted + // owned links, no sources, carrying the 70/30 default. Every post came out + // flagged via_fallback, and maxFallbackItemsPerDay=3 would have stopped the + // campaign entirely on the following tick. + const { client } = makeFakeSupabase( + db({ + promo_link: [link({ id: "owned-1", ownership: "owned" })], + promo_source: [], + promo_post: Array.from({ length: 50 }, (_, i) => ({ + id: `legacy-${i}`, + list_id: "list-a", + ownership: "owned", + status: "posted", + via_fallback: false, + created_at: "2026-08-18T10:00:00.000Z", + })), + }), + ); + const selection = await selectNextLink(client, list, NOW); + expect(selection.link?.id).toBe("owned-1"); + expect(selection.decision.viaFallback).toBe(false); + expect(selection.decision.reason).toBe("on_target"); + }); + it("ignores another list's history", async () => { const { client } = makeFakeSupabase( db({