fix: cubic comments
This commit is contained in:
@@ -364,8 +364,9 @@ async function runTestFile({
|
||||
// A file is considered failed if:
|
||||
// 1. Any individual test failed, OR
|
||||
// 2. The process exited non-zero (e.g. module import error), OR
|
||||
// 3. Zero tests were found (likely a silent import failure)
|
||||
const isFailed = hasFailures || processExitedNonZero || hasNoTests;
|
||||
// 3. Zero tests were found AND process exited non-zero (likely a silent import failure)
|
||||
// Note: Empty files that run successfully (exit 0) are treated as passed/skipped
|
||||
const isFailed = hasFailures || processExitedNonZero;
|
||||
|
||||
const finalResult: TestFileResult = {
|
||||
file,
|
||||
|
||||
@@ -73,6 +73,12 @@ BEGIN
|
||||
WHERE ce.id = ent_id
|
||||
FOR UPDATE;
|
||||
|
||||
-- Skip if the row doesn't exist (stale ID from a deleted cusEnt)
|
||||
IF NOT FOUND THEN
|
||||
skipped_ids := skipped_ids || to_jsonb(ent_id);
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
-- Optimistic lock: skip if next_reset_at already equals the new value
|
||||
IF db_next_reset_at IS NOT DISTINCT FROM new_next_reset_at THEN
|
||||
skipped_ids := skipped_ids || to_jsonb(ent_id);
|
||||
|
||||
@@ -2,11 +2,10 @@ import { CusProductStatus } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import type { BatchResetCusEntsPayload } from "@/queue/workflows.js";
|
||||
import { CusService } from "../../CusService.js";
|
||||
import { resetCustomerEntitlements } from "./resetCustomerEntitlements.js";
|
||||
|
||||
/**
|
||||
* SQS worker handler: fetches cusEnts by ID, groups by customer,
|
||||
* fetches each FullCustomer, and runs the lazy reset logic.
|
||||
* SQS worker handler: fetches each FullCustomer via CusService.getFull,
|
||||
* which triggers the lazy reset internally.
|
||||
*/
|
||||
export const batchResetCustomerEntitlements = async ({
|
||||
ctx,
|
||||
@@ -25,18 +24,13 @@ export const batchResetCustomerEntitlements = async ({
|
||||
const batch = resets.slice(i, i + BATCH_SIZE);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async (reset) => {
|
||||
const fullCus = await CusService.getFull({
|
||||
batch.map((reset) =>
|
||||
CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: reset.internalCustomerId,
|
||||
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
|
||||
});
|
||||
|
||||
await resetCustomerEntitlements({
|
||||
ctx,
|
||||
fullCus,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FullCustomer } from "@autumn/shared";
|
||||
import * as Sentry from "@sentry/bun";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import {
|
||||
type ResetCusEntParam,
|
||||
@@ -53,55 +54,63 @@ export const resetCustomerEntitlements = async ({
|
||||
|
||||
if (cusEntsNeedingReset.length === 0) return false;
|
||||
|
||||
logger.info(
|
||||
`[resetCustomerEntitlements] customer=${customerId}, cusEnts needing reset: ${cusEntsNeedingReset.length}`,
|
||||
);
|
||||
try {
|
||||
logger.info(
|
||||
`[resetCustomerEntitlements] customer=${customerId}, cusEnts needing reset: ${cusEntsNeedingReset.length}`,
|
||||
);
|
||||
|
||||
// 1. Compute all resets (pure computation, no DB writes)
|
||||
const computed: Array<{
|
||||
cusEntId: string;
|
||||
result: ProcessResetResult;
|
||||
}> = [];
|
||||
// 1. Compute all resets (pure computation, no DB writes)
|
||||
const computed: Array<{
|
||||
cusEntId: string;
|
||||
result: ProcessResetResult;
|
||||
}> = [];
|
||||
|
||||
for (const cusEnt of cusEntsNeedingReset) {
|
||||
const result = await processReset({ cusEnt, ctx });
|
||||
if (!result) continue;
|
||||
computed.push({ cusEntId: cusEnt.id, result });
|
||||
}
|
||||
for (const cusEnt of cusEntsNeedingReset) {
|
||||
const result = await processReset({ cusEnt, ctx });
|
||||
if (!result) continue;
|
||||
computed.push({ cusEntId: cusEnt.id, result });
|
||||
}
|
||||
|
||||
if (computed.length === 0) return false;
|
||||
if (computed.length === 0) return false;
|
||||
|
||||
// 2. Execute atomic DB writes via Postgres function
|
||||
const resets = computed.map(({ cusEntId, result }) =>
|
||||
toResetParam({ cusEntId, result }),
|
||||
);
|
||||
// 2. Execute atomic DB writes via Postgres function
|
||||
const resets = computed.map(({ cusEntId, result }) =>
|
||||
toResetParam({ cusEntId, result }),
|
||||
);
|
||||
|
||||
const { applied, skipped } = await resetCusEnts({ ctx, resets });
|
||||
|
||||
logger.info(
|
||||
`[resetCustomerEntitlements] customer=${customerId}, applied: ${Object.keys(applied).length}, skipped: ${skipped.length}`,
|
||||
);
|
||||
|
||||
// 3. Apply computed reset values to in-memory FullCustomer.
|
||||
// Both DB-applied and DB-skipped cusEnts get their in-memory state updated
|
||||
// (skipped means another request already wrote the same values to DB).
|
||||
// Rollover clearing only runs for DB-applied entries.
|
||||
await applyResetResults({ ctx, fullCus, computed, skipped });
|
||||
|
||||
// 4. Update Redis cache atomically (fire-and-forget)
|
||||
// Only needed when we actually wrote to DB — skipped means cache was
|
||||
// already updated by the winning request.
|
||||
if (Object.keys(applied).length > 0) {
|
||||
await executeResetCache({
|
||||
ctx,
|
||||
customerId,
|
||||
resets,
|
||||
});
|
||||
const { applied, skipped } = await resetCusEnts({ ctx, resets });
|
||||
|
||||
logger.info(
|
||||
`[resetCustomerEntitlements] customer=${customerId}, Redis cache updated`,
|
||||
`[resetCustomerEntitlements] customer=${customerId}, applied: ${Object.keys(applied).length}, skipped: ${skipped.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
// 3. Apply computed reset values to in-memory FullCustomer.
|
||||
// Both DB-applied and DB-skipped cusEnts get their in-memory state updated
|
||||
// (skipped means another request already wrote the same values to DB).
|
||||
// Rollover clearing only runs for DB-applied entries.
|
||||
await applyResetResults({ ctx, fullCus, computed, skipped });
|
||||
|
||||
// 4. Update Redis cache atomically (fire-and-forget)
|
||||
// Only needed when we actually wrote to DB — skipped means cache was
|
||||
// already updated by the winning request.
|
||||
if (Object.keys(applied).length > 0) {
|
||||
await executeResetCache({
|
||||
ctx,
|
||||
customerId,
|
||||
resets,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[resetCustomerEntitlements] customer=${customerId}, Redis cache updated`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[resetCustomerEntitlements] customer=${customerId}, failed: ${error}`,
|
||||
);
|
||||
Sentry.captureException(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,6 +23,8 @@ export const triggerBatchResetCustomerEntitlements = async ({
|
||||
now,
|
||||
});
|
||||
|
||||
if (cusEntsNeedingReset.length === 0) continue;
|
||||
|
||||
resets.push({
|
||||
internalCustomerId: fullCus.internal_id,
|
||||
customerId: fullCus.id ?? "",
|
||||
|
||||
@@ -1,205 +1,200 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
ApiVersion,
|
||||
type CheckResponseV2,
|
||||
ResetInterval,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
/**
|
||||
* Sleep until a specific epoch time in milliseconds
|
||||
*/
|
||||
function sleepUntil(epochMs: number): Promise<void> {
|
||||
const delay = epochMs - Date.now();
|
||||
|
||||
if (delay <= 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (delay <= 0) return Promise.resolve();
|
||||
return new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
const messagesFeature = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
});
|
||||
|
||||
const freeProd = constructProduct({
|
||||
type: "free",
|
||||
isDefault: false,
|
||||
items: [messagesFeature],
|
||||
});
|
||||
|
||||
const testCase = "check-loose-expiry";
|
||||
|
||||
describe(`${chalk.yellowBright(`${testCase}: expiring loose entitlement check`)}`, () => {
|
||||
const customerBasic = `${testCase}-basic`;
|
||||
const customerProductMix = `${testCase}-prod`;
|
||||
const customerResetMix = `${testCase}-reset`;
|
||||
test.concurrent(chalk.yellowBright(`${testCase}-basic: expiring loose entitlement should be allowed before expiry, then denied after`), async () => {
|
||||
const customerId = `${testCase}-basic`;
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const freeProd = products.base({ id: "free", items: [messagesItem] });
|
||||
|
||||
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
|
||||
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
|
||||
beforeAll(async () => {
|
||||
// Setup products
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [freeProd],
|
||||
prefix: testCase,
|
||||
});
|
||||
|
||||
// Setup customers only
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId: customerBasic,
|
||||
withTestClock: false,
|
||||
});
|
||||
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId: customerProductMix,
|
||||
withTestClock: false,
|
||||
});
|
||||
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId: customerResetMix,
|
||||
withTestClock: false,
|
||||
});
|
||||
const { ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
test("basic: expiring loose entitlement should be allowed before expiry, then denied after", async () => {
|
||||
const expiresAt = Date.now() + 3000;
|
||||
const autumnV1 = new AutumnInt({
|
||||
version: ApiVersion.V1_2,
|
||||
secretKey: ctx.orgSecretKey,
|
||||
});
|
||||
const autumnV2 = new AutumnInt({
|
||||
version: ApiVersion.V2_0,
|
||||
secretKey: ctx.orgSecretKey,
|
||||
});
|
||||
|
||||
// Create expiring loose entitlement
|
||||
await autumnV1.balances.create({
|
||||
customer_id: customerBasic,
|
||||
const expiresAt = Date.now() + 3000;
|
||||
|
||||
await autumnV1.balances.create({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
granted_balance: 500,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
const resBefore = (await autumnV2.check({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
})) as unknown as CheckResponseV2;
|
||||
|
||||
expect(resBefore).toMatchObject({
|
||||
allowed: true,
|
||||
customer_id: customerId,
|
||||
balance: {
|
||||
plan_id: null,
|
||||
feature_id: TestFeature.Messages,
|
||||
granted_balance: 500,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
// Check before expiry
|
||||
const resBefore = (await autumnV2.check({
|
||||
customer_id: customerBasic,
|
||||
feature_id: TestFeature.Messages,
|
||||
})) as unknown as CheckResponseV2;
|
||||
|
||||
expect(resBefore.allowed).toBe(true);
|
||||
expect(resBefore.customer_id).toBe(customerBasic);
|
||||
expect(resBefore.balance).toBeDefined();
|
||||
expect(resBefore.balance?.plan_id).toBeNull();
|
||||
expect(resBefore.balance?.feature_id).toBe(TestFeature.Messages);
|
||||
expect(resBefore.balance?.granted_balance).toBe(500);
|
||||
expect(resBefore.balance?.current_balance).toBe(500);
|
||||
expect(resBefore.balance?.usage).toBe(0);
|
||||
expect(resBefore.balance?.unlimited).toBe(false);
|
||||
|
||||
// Wait until expiry
|
||||
await sleepUntil(expiresAt + 1000);
|
||||
|
||||
// Check after expiry
|
||||
const resAfter = (await autumnV2.check({
|
||||
customer_id: customerBasic,
|
||||
feature_id: TestFeature.Messages,
|
||||
})) as unknown as CheckResponseV2;
|
||||
|
||||
expect(resAfter.allowed).toBe(false);
|
||||
expect(resAfter.balance).toBeNull();
|
||||
current_balance: 500,
|
||||
usage: 0,
|
||||
unlimited: false,
|
||||
},
|
||||
});
|
||||
|
||||
test("product-mix: should combine product and expiring loose ent, then only product after expiry", async () => {
|
||||
const expiresAt = Date.now() + 3000;
|
||||
await sleepUntil(expiresAt + 1000);
|
||||
|
||||
// Attach product with 100 messages
|
||||
await autumnV1.attach({
|
||||
customer_id: customerProductMix,
|
||||
product_id: freeProd.id,
|
||||
});
|
||||
const resAfter = (await autumnV2.check({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
})) as unknown as CheckResponseV2;
|
||||
|
||||
// Create expiring loose entitlement with 200 messages
|
||||
await autumnV1.balances.create({
|
||||
customer_id: customerProductMix,
|
||||
feature_id: TestFeature.Messages,
|
||||
granted_balance: 200,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
expect(resAfter).toMatchObject({
|
||||
allowed: false,
|
||||
balance: null,
|
||||
});
|
||||
});
|
||||
|
||||
// Check before expiry
|
||||
const resBefore = (await autumnV2.check({
|
||||
customer_id: customerProductMix,
|
||||
feature_id: TestFeature.Messages,
|
||||
})) as unknown as CheckResponseV2;
|
||||
test.concurrent(chalk.yellowBright(`${testCase}-product-mix: should combine product and expiring loose ent, then only product after expiry`), async () => {
|
||||
const customerId = `${testCase}-product-mix`;
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const freeProd = products.base({ id: "free", items: [messagesItem] });
|
||||
|
||||
expect(resBefore.allowed).toBe(true);
|
||||
expect(resBefore.balance?.granted_balance).toBe(300); // 100 from product + 200 from loose
|
||||
expect(resBefore.balance?.current_balance).toBe(300);
|
||||
|
||||
// Wait until expiry
|
||||
await sleepUntil(expiresAt + 1000);
|
||||
|
||||
// Check after expiry
|
||||
const resAfter = (await autumnV2.check({
|
||||
customer_id: customerProductMix,
|
||||
feature_id: TestFeature.Messages,
|
||||
})) as unknown as CheckResponseV2;
|
||||
|
||||
expect(resAfter.allowed).toBe(true);
|
||||
expect(resAfter.balance?.granted_balance).toBe(100); // Only product balance remains
|
||||
expect(resAfter.balance?.current_balance).toBe(100);
|
||||
const { autumnV1, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||
actions: [s.attach({ productId: freeProd.id })],
|
||||
});
|
||||
|
||||
test("reset-mix: should combine expiring and resetting loose ents, then only resetting after expiry", async () => {
|
||||
const expiresAt = Date.now() + 3000;
|
||||
const autumnV2 = new AutumnInt({
|
||||
version: ApiVersion.V2_0,
|
||||
secretKey: ctx.orgSecretKey,
|
||||
});
|
||||
|
||||
// Create expiring loose entitlement
|
||||
await autumnV1.balances.create({
|
||||
customer_id: customerResetMix,
|
||||
feature_id: TestFeature.Messages,
|
||||
granted_balance: 200,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
const expiresAt = Date.now() + 3000;
|
||||
|
||||
// Create resetting loose entitlement (no expiry)
|
||||
await autumnV1.balances.create({
|
||||
customer_id: customerResetMix,
|
||||
feature_id: TestFeature.Messages,
|
||||
granted_balance: 100,
|
||||
await autumnV1.balances.create({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
granted_balance: 200,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
const resBefore = (await autumnV2.check({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
})) as unknown as CheckResponseV2;
|
||||
|
||||
expect(resBefore).toMatchObject({
|
||||
allowed: true,
|
||||
balance: {
|
||||
granted_balance: 300, // 100 from product + 200 from loose
|
||||
current_balance: 300,
|
||||
},
|
||||
});
|
||||
|
||||
await sleepUntil(expiresAt + 1000);
|
||||
|
||||
const resAfter = (await autumnV2.check({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
})) as unknown as CheckResponseV2;
|
||||
|
||||
expect(resAfter).toMatchObject({
|
||||
allowed: true,
|
||||
balance: {
|
||||
granted_balance: 100, // Only product balance remains
|
||||
current_balance: 100,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(chalk.yellowBright(`${testCase}-reset-mix: should combine expiring and resetting loose ents, then only resetting after expiry`), async () => {
|
||||
const customerId = `${testCase}-reset-mix`;
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const freeProd = products.base({ id: "free", items: [messagesItem] });
|
||||
|
||||
const { autumnV1, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const autumnV2 = new AutumnInt({
|
||||
version: ApiVersion.V2_0,
|
||||
secretKey: ctx.orgSecretKey,
|
||||
});
|
||||
|
||||
const expiresAt = Date.now() + 3000;
|
||||
|
||||
await autumnV1.balances.create({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
granted_balance: 200,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
await autumnV1.balances.create({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
granted_balance: 100,
|
||||
reset: {
|
||||
interval: ResetInterval.Month,
|
||||
},
|
||||
});
|
||||
|
||||
const resBefore = (await autumnV2.check({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
})) as unknown as CheckResponseV2;
|
||||
|
||||
expect(resBefore).toMatchObject({
|
||||
allowed: true,
|
||||
balance: {
|
||||
granted_balance: 300, // 200 expiring + 100 resetting
|
||||
current_balance: 300,
|
||||
},
|
||||
});
|
||||
|
||||
await sleepUntil(expiresAt + 1000);
|
||||
|
||||
const resAfter = (await autumnV2.check({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
})) as unknown as CheckResponseV2;
|
||||
|
||||
expect(resAfter).toMatchObject({
|
||||
allowed: true,
|
||||
balance: {
|
||||
granted_balance: 100, // Only resetting balance remains
|
||||
current_balance: 100,
|
||||
reset: {
|
||||
interval: ResetInterval.Month,
|
||||
},
|
||||
});
|
||||
|
||||
// Check before expiry
|
||||
const resBefore = (await autumnV2.check({
|
||||
customer_id: customerResetMix,
|
||||
feature_id: TestFeature.Messages,
|
||||
})) as unknown as CheckResponseV2;
|
||||
|
||||
expect(resBefore.allowed).toBe(true);
|
||||
expect(resBefore.balance?.granted_balance).toBe(300); // 200 expiring + 100 resetting
|
||||
expect(resBefore.balance?.current_balance).toBe(300);
|
||||
|
||||
// Wait until expiry
|
||||
await sleepUntil(expiresAt + 1000);
|
||||
|
||||
// Check after expiry
|
||||
const resAfter = (await autumnV2.check({
|
||||
customer_id: customerResetMix,
|
||||
feature_id: TestFeature.Messages,
|
||||
})) as unknown as CheckResponseV2;
|
||||
|
||||
expect(resAfter.allowed).toBe(true);
|
||||
expect(resAfter.balance?.granted_balance).toBe(100); // Only resetting balance remains
|
||||
expect(resAfter.balance?.current_balance).toBe(100);
|
||||
expect(resAfter.balance?.reset).toBeDefined();
|
||||
expect(resAfter.balance?.reset?.interval).toBe(ResetInterval.Month);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user