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
32 changes: 32 additions & 0 deletions packages/kit/convex/products/play.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,44 @@ import { describe, expect, it } from "vitest";

import {
basePlanIdForPeriod,
mapModernPlayOneTimeState,
moneyToMicros,
playPriceMicrosToNumber,
shouldFallbackToLegacyOneTimeProduct,
upsertModernAndroidOneTimeProduct,
} from "./play";

describe("mapModernPlayOneTimeState", () => {
it("maps the modern purchase-option lifecycle without assuming availability", () => {
expect(mapModernPlayOneTimeState([{ state: "DRAFT" }])).toBe("Draft");
expect(mapModernPlayOneTimeState([{ state: "INACTIVE" }])).toBe("Removed");
expect(mapModernPlayOneTimeState([{ state: "INACTIVE_PUBLISHED" }])).toBe(
"Removed",
);
expect(mapModernPlayOneTimeState(undefined)).toBe("Removed");
expect(mapModernPlayOneTimeState(null)).toBe("Removed");
expect(mapModernPlayOneTimeState([])).toBe("Removed");
expect(mapModernPlayOneTimeState([{ state: "STATE_UNSPECIFIED" }])).toBe(
"Removed",
);
expect(mapModernPlayOneTimeState([{ state: "FUTURE_STATE" }])).toBe(
"Removed",
);
});

it("keeps a product active when any purchase option remains active", () => {
expect(
mapModernPlayOneTimeState([{ state: "INACTIVE" }, { state: "ACTIVE" }]),
).toBe("Active");
});

it("treats mixed inactive and draft options as unavailable", () => {
expect(
mapModernPlayOneTimeState([{ state: "DRAFT" }, { state: "INACTIVE" }]),
).toBe("Removed");
});
});

describe("upsertModernAndroidOneTimeProduct", () => {
it("uses the generated lowercase one-time-product PATCH route", async () => {
let capturedRequest: Common.GaxiosOptions | undefined;
Expand Down
20 changes: 19 additions & 1 deletion packages/kit/convex/products/play.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ async function performAndroidSync(
priceAmountMicros,
currency: preferred?.currencyCode ?? undefined,
storeRef: product.productId,
state: "Active",
state: mapModernPlayOneTimeState(product.purchaseOptions),
});
pulled += 1;
}
Expand Down Expand Up @@ -1240,6 +1240,24 @@ function mapPlayStatus(
}
}

export function mapModernPlayOneTimeState(
purchaseOptions: Array<{ state?: string | null }> | null | undefined,
): "Draft" | "Active" | "Removed" {
const states = (purchaseOptions ?? []).map((option) =>
option.state?.toUpperCase(),
);
if (states.includes("ACTIVE")) return "Active";
if (states.includes("INACTIVE") || states.includes("INACTIVE_PUBLISHED")) {
return "Removed";
}
if (states.length > 0 && states.every((state) => state === "DRAFT")) {
return "Draft";
}
// Missing or future states must not become a kit Draft: a `both` sync would
// treat that as an instruction to push and could make the option sellable.
return "Removed";
}

function pickPlayTitle(
product: androidpublisher_v3.Schema$InAppProduct,
): string | undefined {
Expand Down
101 changes: 101 additions & 0 deletions packages/kit/convex/products/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
deleteRemovedProductRow as registeredDeleteRemovedProductRow,
isSafePriceAmountMicros,
listDraftIosProducts as registeredListDraftIosProducts,
listRemovedAndroidProducts as registeredListRemovedAndroidProducts,
markPushed as registeredMarkPushed,
shouldPreserveKitRemovedDuringPull,
upsertFromStore as registeredUpsertFromStore,
Expand All @@ -18,6 +19,9 @@ const deleteRemovedProductRow = testableFunction(
);
const upsertFromStore = testableFunction(registeredUpsertFromStore);
const listDraftIosProducts = testableFunction(registeredListDraftIosProducts);
const listRemovedAndroidProducts = testableFunction(
registeredListRemovedAndroidProducts,
);
const markPushed = testableFunction(registeredMarkPushed);

type Row = Record<string, unknown> & { _id: string };
Expand Down Expand Up @@ -128,6 +132,103 @@ describe("shouldPreserveKitRemovedDuringPull", () => {
});
});

describe("upsertFromStore removal provenance", () => {
function writableDb(product: Row) {
return new TestDb({
organizations: [{ _id: "organization_a", pendingDeletion: false }],
projects: [
{
_id: "project_a",
organizationId: "organization_a",
pendingDeletion: false,
},
],
products: [product],
});
}

it("marks a store-reported removal as store-authored", async () => {
const product = {
_id: "product_a",
projectId: "project_a",
platform: "Android",
productId: "legacy_bundle",
type: "NonConsumable",
title: "Legacy bundle",
state: "Active",
origin: "kit",
storeRef: "legacy_bundle",
updatedAt: 1,
};
const db = writableDb(product);

await upsertFromStore._handler(
{ db },
{
projectId: "project_a" as never,
productId: "legacy_bundle",
platform: "Android",
type: "NonConsumable",
title: "Legacy bundle",
storeRef: "legacy_bundle",
state: "Removed",
},
);

expect(product).toMatchObject({ state: "Removed", origin: "store" });
await expect(
listRemovedAndroidProducts._handler(
{ db },
{ projectId: "project_a" as never },
),
).resolves.toEqual([]);
});

it("preserves an explicit kit removal for the push phase", async () => {
const product = {
_id: "product_a",
projectId: "project_a",
platform: "Android",
productId: "legacy_bundle",
type: "NonConsumable",
title: "Legacy bundle",
state: "Removed",
origin: "kit",
storeRef: "legacy_bundle",
updatedAt: 1,
};
const db = writableDb(product);

await upsertFromStore._handler(
{ db },
{
projectId: "project_a" as never,
productId: "legacy_bundle",
platform: "Android",
type: "NonConsumable",
title: "Legacy bundle",
storeRef: "legacy_bundle",
state: "Removed",
},
);

expect(product).toMatchObject({ state: "Removed", origin: "kit" });
await expect(
listRemovedAndroidProducts._handler(
{ db },
{ projectId: "project_a" as never },
),
).resolves.toEqual([
{
productId: "legacy_bundle",
platform: "Android",
type: "NonConsumable",
storeRef: "legacy_bundle",
},
]);
});
});

describe("listDraftIosProducts review resumption", () => {
it("includes Ready rows that have not handled the configured screenshot", async () => {
const base = {
Expand Down
17 changes: 10 additions & 7 deletions packages/kit/convex/products/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,16 @@ export const upsertFromStore = internalMutation({
offers: args.offers,
syncedAt: now,
updatedAt: now,
// Preserve `origin` on UPDATE — only set it if absent
// (back-fill for rows created before this field existed).
// A pulled row touched later by `upsertProduct` keeps
// `origin: "store"`; a kit row touched by pull-sync keeps
// `origin: "kit"`. The push filter then correctly excludes
// pulled-from-store rows even after they're re-touched.
...(existing.origin === undefined ? { origin: "store" as const } : {}),
// A store-reported removal is upstream state, not a new kit-authored
// delete request. Reclassify it so the push half of a `both` job does
// not attempt to delete the already-unavailable resource. An explicit
// kit removal returned above before reaching this patch and therefore
// keeps its deletion intent. Preserve origin for all other updates.
...(args.state === "Removed"
? { origin: "store" as const }
: existing.origin === undefined
? { origin: "store" as const }
: {}),
});
return existing._id;
}
Expand Down