fix: versioning track / events request body
This commit is contained in:
@@ -13,6 +13,7 @@ source "$(dirname "$0")/config.sh"
|
||||
|
||||
BUN_PARALLEL_V2 \
|
||||
'integration/balances/check' \
|
||||
'integration/balances/track' \
|
||||
'balances/track/basic' \
|
||||
'balances/track/concurrency' \
|
||||
'balances/track/breakdown' \
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { ErrCode, RecaseError } from "@autumn/shared";
|
||||
import type { Context, Next } from "hono";
|
||||
import { redis } from "@/external/redis/initRedis.js";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils";
|
||||
import { checkIdempotencyKey } from "@/internal/misc/idempotency/checkIdempotencyKey.js";
|
||||
|
||||
/**
|
||||
* Middleware that checks for idempotence in a request
|
||||
@@ -17,19 +15,11 @@ export const idempotencyMiddleware = async (
|
||||
headers["idempotency-key"] || headers["Idempotency-Key"];
|
||||
|
||||
if (idempotencyKey) {
|
||||
const redisKey = `${ctx.org.id}:${ctx.env}:idempotency:${idempotencyKey}`;
|
||||
// Use SET NX (set if not exists) for atomic check-and-set to prevent race conditions
|
||||
const wasSet = await tryRedisWrite(() => {
|
||||
return redis.set(redisKey, "1", "PX", 1000 * 60 * 60 * 24, "NX"); // 24 hours, only set if not exists
|
||||
await checkIdempotencyKey({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
if (!wasSet) {
|
||||
throw new RecaseError({
|
||||
message: `Another request with idempotency key ${idempotencyKey} has already been received`,
|
||||
code: ErrCode.DuplicateIdempotencyKey,
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { TrackParamsSchema, TrackQuerySchema } from "@autumn/shared";
|
||||
import {
|
||||
AffectedResource,
|
||||
ApiVersion,
|
||||
TrackParamsSchema,
|
||||
TrackParamsV0Schema,
|
||||
TrackQuerySchema,
|
||||
} from "@autumn/shared";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { runTrackV2 } from "@/internal/balances/track/runTrackV2.js";
|
||||
import {
|
||||
@@ -8,19 +14,15 @@ import {
|
||||
|
||||
export const handleTrack = createRoute({
|
||||
query: TrackQuerySchema,
|
||||
body: TrackParamsSchema,
|
||||
versionedBody: {
|
||||
latest: TrackParamsSchema,
|
||||
[ApiVersion.V1_Beta]: TrackParamsV0Schema,
|
||||
},
|
||||
resource: AffectedResource.Track,
|
||||
handler: async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const ctx = c.get("ctx");
|
||||
|
||||
// Legacy: support value in properties
|
||||
if (body.properties?.value) {
|
||||
const parsedValue = Number(body.properties.value);
|
||||
if (!Number.isNaN(parsedValue)) {
|
||||
body.value = parsedValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Build feature deductions
|
||||
const featureDeductions = body.feature_id
|
||||
? getTrackFeatureDeductions({
|
||||
|
||||
@@ -47,7 +47,6 @@ export const runTrackV2 = async ({
|
||||
await handleEventIdempotencyKey({
|
||||
ctx,
|
||||
body,
|
||||
fullCustomer,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
import type { FullCustomer, TrackParams } from "@autumn/shared";
|
||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
||||
import { EventService } from "../../../api/events/EventService";
|
||||
import { buildEventInfo, initEvent } from "../../events/initEvent";
|
||||
import type { TrackParams } from "@autumn/shared";
|
||||
import { checkIdempotencyKey } from "@/internal/misc/idempotency/checkIdempotencyKey.js";
|
||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
|
||||
|
||||
export const handleEventIdempotencyKey = async ({
|
||||
ctx,
|
||||
body,
|
||||
fullCustomer,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
body: TrackParams;
|
||||
fullCustomer: FullCustomer;
|
||||
}) => {
|
||||
const eventInfo = buildEventInfo(body);
|
||||
|
||||
const newEvent = initEvent({
|
||||
ctx,
|
||||
eventInfo,
|
||||
internalCustomerId: fullCustomer.internal_id,
|
||||
internalEntityId: fullCustomer.entity?.internal_id ?? undefined,
|
||||
customerId: body.customer_id,
|
||||
entityId: body.entity_id,
|
||||
await checkIdempotencyKey({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
idempotencyKey: `track:${body.idempotency_key}`,
|
||||
});
|
||||
|
||||
await EventService.insert({
|
||||
db: ctx.db,
|
||||
event: newEvent,
|
||||
});
|
||||
// const eventInfo = buildEventInfo(body);
|
||||
|
||||
body.skip_event = true;
|
||||
// const newEvent = initEvent({
|
||||
// ctx,
|
||||
// eventInfo,
|
||||
// internalCustomerId: fullCustomer.internal_id,
|
||||
// internalEntityId: fullCustomer.entity?.internal_id ?? undefined,
|
||||
// customerId: body.customer_id,
|
||||
// entityId: body.entity_id,
|
||||
// });
|
||||
|
||||
// await EventService.insert({
|
||||
// db: ctx.db,
|
||||
// event: newEvent,
|
||||
// });
|
||||
|
||||
// body.skip_event = true;
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ const queueEvent = ({
|
||||
body: TrackParams;
|
||||
fullCustomer: FullCustomer;
|
||||
}): void => {
|
||||
if (body.skip_event || body.idempotency_key) return;
|
||||
if (body.skip_event) return;
|
||||
|
||||
const eventInfo = buildEventInfo(body);
|
||||
|
||||
|
||||
52
server/src/internal/misc/idempotency/checkIdempotencyKey.ts
Normal file
52
server/src/internal/misc/idempotency/checkIdempotencyKey.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ErrCode, RecaseError } from "@autumn/shared";
|
||||
import { redis } from "@/external/redis/initRedis.js";
|
||||
|
||||
const IDEMPOTENCY_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
|
||||
|
||||
/**
|
||||
* Checks and sets an idempotency key in Redis using atomic SET NX operation.
|
||||
* If Redis is not ready, allows the request to proceed (fail-open).
|
||||
* Throws if the key already exists (duplicate request).
|
||||
*/
|
||||
export const checkIdempotencyKey = async ({
|
||||
orgId,
|
||||
env,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
orgId: string;
|
||||
env: string;
|
||||
idempotencyKey: string;
|
||||
}): Promise<void> => {
|
||||
// Fail-open: if Redis is not ready, allow the request
|
||||
if (redis.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
const redisKey = `${orgId}:${env}:idempotency:${idempotencyKey}`;
|
||||
|
||||
try {
|
||||
// Use SET NX (set if not exists) for atomic check-and-set to prevent race conditions
|
||||
const wasSet = await redis.set(
|
||||
redisKey,
|
||||
"1",
|
||||
"PX",
|
||||
IDEMPOTENCY_TTL_MS,
|
||||
"NX",
|
||||
);
|
||||
|
||||
if (!wasSet) {
|
||||
throw new RecaseError({
|
||||
message: `Another request with idempotency key ${idempotencyKey} has already been received`,
|
||||
code: ErrCode.DuplicateIdempotencyKey,
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Re-throw RecaseError (duplicate key)
|
||||
if (error instanceof RecaseError) {
|
||||
throw error;
|
||||
}
|
||||
// For other Redis errors, fail-open (allow request)
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -26,9 +26,14 @@ const freeProd = constructProduct({
|
||||
|
||||
const testCase = "track-basic6";
|
||||
|
||||
// Generate unique idempotency keys per test run to avoid Redis TTL conflicts
|
||||
const testRunId = Date.now().toString(36);
|
||||
|
||||
describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents duplicate tracks")}`, () => {
|
||||
const customerId = "track-basic6";
|
||||
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
const idempotencyKey1 = `test-idempotency-key-1-${testRunId}`;
|
||||
const idempotencyKey2 = `test-idempotency-key-2-${testRunId}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initCustomerV3({
|
||||
@@ -58,13 +63,12 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl
|
||||
|
||||
test("should process first track with idempotency key", async () => {
|
||||
const deductValue = 25.5;
|
||||
const idempotencyKey = "test-idempotency-key-1";
|
||||
|
||||
await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: deductValue,
|
||||
idempotency_key: idempotencyKey,
|
||||
idempotency_key: idempotencyKey1,
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
@@ -76,28 +80,28 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl
|
||||
expect(balance).toBe(expectedBalance);
|
||||
expect(usage).toBe(deductValue);
|
||||
|
||||
await timeout(2000);
|
||||
const eventsList = await getCustomerEvents({ customerId });
|
||||
expect(eventsList).toHaveLength(1);
|
||||
expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey);
|
||||
expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey1);
|
||||
expect(eventsList?.[0].value).toBe(deductValue);
|
||||
});
|
||||
|
||||
test("should reject second track with same idempotency key", async () => {
|
||||
const deductValue = 30.75; // Different value
|
||||
const idempotencyKey = "test-idempotency-key-1"; // Same key
|
||||
|
||||
// Get balance before attempting duplicate track
|
||||
const customerBefore = await autumnV1.customers.get(customerId);
|
||||
const balanceBefore = customerBefore.features[TestFeature.Messages].balance;
|
||||
// This should fail or be rejected due to duplicate idempotency key
|
||||
await expectAutumnError({
|
||||
errCode: ErrCode.DuplicateEvent,
|
||||
errCode: ErrCode.DuplicateIdempotencyKey,
|
||||
func: async () => {
|
||||
await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: deductValue,
|
||||
idempotency_key: idempotencyKey,
|
||||
idempotency_key: idempotencyKey1, // Same key as first test
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -117,12 +121,11 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl
|
||||
|
||||
const eventsList = await getCustomerEvents({ customerId });
|
||||
expect(eventsList).toHaveLength(1);
|
||||
expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey);
|
||||
expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey1);
|
||||
});
|
||||
|
||||
test("should process track with different idempotency key", async () => {
|
||||
const deductValue = 15.25;
|
||||
const idempotencyKey = "test-idempotency-key-2"; // Different key
|
||||
|
||||
const customerBefore = await autumnV1.customers.get(customerId);
|
||||
const balanceBefore = customerBefore.features[TestFeature.Messages].balance;
|
||||
@@ -131,7 +134,7 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: deductValue,
|
||||
idempotency_key: idempotencyKey,
|
||||
idempotency_key: idempotencyKey2, // Different key
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
|
||||
@@ -24,7 +24,7 @@ test(
|
||||
items: [allocatedUsersItem, priceItem],
|
||||
});
|
||||
|
||||
const uniqueId = `paid-alloc-lock-${Date.now()}`;
|
||||
const uniqueId = `paid-alloc-lock`;
|
||||
const { customerId, autumnV2 } = await initScenario({
|
||||
customerId: uniqueId,
|
||||
setup: [
|
||||
|
||||
330
server/tests/integration/balances/track/track-misc.test.ts
Normal file
330
server/tests/integration/balances/track/track-misc.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
type ApiEntityV0,
|
||||
CusExpand,
|
||||
type TrackResponseV2,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { timeout } from "@tests/utils/genUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { EventService } from "@/internal/api/events/EventService.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-MISC1: Auto-create customer and entity via track
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("track-misc1: track auto-creates customer and entity")}`, async () => {
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const freeProd = products.base({
|
||||
id: "free",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const customerId = "track-misc1";
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
setup: [
|
||||
s.deleteCustomer({ customerId: "track-misc1" }),
|
||||
s.products({ list: [freeProd], prefix: customerId }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const entityId = `${customerId}-entity-1`;
|
||||
|
||||
await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
customer_data: {
|
||||
name: "Test Customer",
|
||||
email: "test@test.com",
|
||||
},
|
||||
feature_id: TestFeature.Messages,
|
||||
entity_id: entityId,
|
||||
entity_data: {
|
||||
name: "Test Entity",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
value: 5,
|
||||
});
|
||||
|
||||
// Verify customer was created with provided data
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expect(customer).toMatchObject({
|
||||
id: customerId,
|
||||
name: "Test Customer",
|
||||
email: "test@test.com",
|
||||
});
|
||||
|
||||
// Verify entity was created with provided data
|
||||
const entity = await autumnV1.entities.get<ApiEntityV0>(customerId, entityId);
|
||||
expect(entity).toMatchObject({
|
||||
id: entityId,
|
||||
name: "Test Entity",
|
||||
});
|
||||
|
||||
// Verify customer.entities includes the created entity
|
||||
const customerWithEntities = await autumnV1.customers.get<ApiCustomerV3>(
|
||||
customerId,
|
||||
{ expand: [CusExpand.Entities] },
|
||||
);
|
||||
expect(customerWithEntities.entities).toBeDefined();
|
||||
expect(customerWithEntities.entities).toHaveLength(1);
|
||||
expect(customerWithEntities.entities?.[0].id).toBe(entityId);
|
||||
expect(customerWithEntities.entities?.[0].name).toBe("Test Entity");
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-MISC2: Track event stores properties
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("track-misc2: track event stores custom properties")}`, async () => {
|
||||
const { customerId, autumnV1 } = await initScenario({
|
||||
customerId: "track-misc2",
|
||||
setup: [s.customer({ testClock: false })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
customer_data: {
|
||||
name: "track-misc2",
|
||||
email: "track-misc2@test.com",
|
||||
},
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 5,
|
||||
properties: {
|
||||
hello: "world",
|
||||
foo: "bar",
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
|
||||
with_autumn_id: true,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
|
||||
const events = await EventService.getByCustomerId({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
internalCustomerId: customer.autumn_id!,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events?.[0].properties).toMatchObject({
|
||||
hello: "world",
|
||||
foo: "bar",
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-MISC3: Track creates events when balance is empty
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("track-misc3: track creates events when customer has no balance")}`, async () => {
|
||||
const { customerId, autumnV1 } = await initScenario({
|
||||
customerId: "track-misc3",
|
||||
setup: [s.customer({ testClock: false })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const trackCount = Math.floor(Math.random() * 10) + 1;
|
||||
let totalValue = 0;
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: trackCount }, () => {
|
||||
const trackValue = Math.random() * 10;
|
||||
totalValue += trackValue;
|
||||
return autumnV1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: trackValue,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
|
||||
with_autumn_id: true,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
|
||||
const events = await EventService.getByCustomerId({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
internalCustomerId: customer.autumn_id ?? "",
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(trackCount);
|
||||
expect(events.reduce((acc, event) => acc + (event.value ?? 0), 0)).toBe(
|
||||
totalValue,
|
||||
);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-MISC4: Track v1.2 response format
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("track-misc4: track returns correct v1.2 response format")}`, async () => {
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const freeProd = products.base({
|
||||
id: "free",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1 } = await initScenario({
|
||||
customerId: "track-misc4",
|
||||
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||
actions: [s.attach({ productId: freeProd.id })],
|
||||
});
|
||||
|
||||
const trackRes: TrackResponseV2 = await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 20,
|
||||
});
|
||||
|
||||
expect(trackRes).toMatchObject({
|
||||
id: "placeholder",
|
||||
code: "event_received",
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-MISC5: V1.2 properties.value maps to value field
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("track-misc5: V1.2 properties.value maps to value field")}`, async () => {
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const freeProd = products.base({
|
||||
id: "free",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1 } = await initScenario({
|
||||
customerId: "track-misc5",
|
||||
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
|
||||
actions: [s.attach({ productId: freeProd.id })],
|
||||
});
|
||||
|
||||
// Track using V1.2 legacy format with properties.value
|
||||
await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
properties: {
|
||||
value: 42.1532,
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
|
||||
with_autumn_id: true,
|
||||
});
|
||||
|
||||
// Verify balance was deducted correctly
|
||||
expect(customer.features[TestFeature.Messages].balance).toBe(
|
||||
new Decimal(100).sub(42.1532).toNumber(),
|
||||
);
|
||||
expect(customer.features[TestFeature.Messages].usage).toBe(42.1532);
|
||||
|
||||
await timeout(2000);
|
||||
|
||||
const events = await EventService.getByCustomerId({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
internalCustomerId: customer.autumn_id!,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].value).toBe(42.1532);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-MISC7: Track defaults to value: 1 when no value provided
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("track-misc7: track defaults to value 1 when no value provided")}`, async () => {
|
||||
const { customerId, autumnV1 } = await initScenario({
|
||||
customerId: "track-misc7",
|
||||
setup: [s.customer({ testClock: false })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Track without providing any value
|
||||
await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
|
||||
with_autumn_id: true,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
|
||||
const events = await EventService.getByCustomerId({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
internalCustomerId: customer.autumn_id!,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].value).toBe(1);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-MISC8: V1.2 properties.value is removed from properties after extraction
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("track-misc8: V1.2 properties.value is removed from stored properties")}`, async () => {
|
||||
const { customerId, autumnV1 } = await initScenario({
|
||||
customerId: "track-misc8",
|
||||
setup: [s.customer({ testClock: false })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Track using V1.2 legacy format with properties.value and other properties
|
||||
await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
properties: {
|
||||
value: 25,
|
||||
hello: "world",
|
||||
foo: "bar",
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
|
||||
with_autumn_id: true,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
|
||||
const events = await EventService.getByCustomerId({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
internalCustomerId: customer.autumn_id!,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].value).toBe(25);
|
||||
// Verify value was removed from properties but other props remain
|
||||
expect(events[0].properties).toMatchObject({
|
||||
hello: "world",
|
||||
foo: "bar",
|
||||
});
|
||||
expect(events[0].properties).not.toHaveProperty("value");
|
||||
});
|
||||
38
shared/api/balances/track/prevVersions/trackParamsV0.ts
Normal file
38
shared/api/balances/track/prevVersions/trackParamsV0.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod/v4";
|
||||
import { CustomerDataSchema } from "../../../common/customerData.js";
|
||||
import { EntityDataSchema } from "../../../common/entityData.js";
|
||||
|
||||
/**
|
||||
* TrackParamsV0Schema - V1.2 and earlier format
|
||||
*
|
||||
* In V1.2, the `value` field could be passed either as a top-level field OR
|
||||
* inside `properties.value`. This schema supports both, with the transformation
|
||||
* extracting `properties.value` only if top-level `value` is not provided.
|
||||
*/
|
||||
export const TrackParamsV0Schema = z
|
||||
.object({
|
||||
customer_id: z.string().nonempty(),
|
||||
feature_id: z.string().optional(),
|
||||
event_name: z.string().nonempty().optional(),
|
||||
value: z.number().optional(),
|
||||
properties: z.record(z.string(), z.any()).optional(),
|
||||
timestamp: z.number().optional(),
|
||||
idempotency_key: z.string().optional(),
|
||||
customer_data: CustomerDataSchema.optional(),
|
||||
entity_id: z.string().optional(),
|
||||
entity_data: EntityDataSchema.optional(),
|
||||
overage_behavior: z.enum(["cap", "reject"]).optional(),
|
||||
skip_event: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.feature_id && data.event_name) return false;
|
||||
if (!data.feature_id && !data.event_name) return false;
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Either feature_id or event_name must be provided",
|
||||
},
|
||||
);
|
||||
|
||||
export type TrackParamsV0 = z.infer<typeof TrackParamsV0Schema>;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
defineVersionChange,
|
||||
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
||||
import type { z } from "zod/v4";
|
||||
import { TrackParamsV0Schema } from "../prevVersions/trackParamsV0.js";
|
||||
import { TrackParamsSchema } from "../trackParams.js";
|
||||
|
||||
/**
|
||||
* V1_2_TrackParamsChange: Transforms track request body TO latest format
|
||||
*
|
||||
* Applied when: sourceVersion <= V1.2
|
||||
*
|
||||
* Breaking changes introduced in V2.0 (that we transform here):
|
||||
*
|
||||
* 1. Value field location:
|
||||
* - V1.2: `properties.value` (value passed inside properties object)
|
||||
* - V2.0+: `value` (top-level field)
|
||||
*
|
||||
* This transformation extracts `properties.value` and maps it to the
|
||||
* top-level `value` field for V1.2 clients, ensuring they can continue
|
||||
* using the legacy format.
|
||||
*
|
||||
* Input: TrackParamsV0 (V1.2 format with properties.value)
|
||||
* Output: TrackParamsV1 (V2.0+ format with top-level value)
|
||||
*/
|
||||
export const V1_2_TrackParamsChange = defineVersionChange({
|
||||
name: "V1_2 Track Params Change",
|
||||
newVersion: ApiVersion.V2_0,
|
||||
oldVersion: ApiVersion.V1_Beta,
|
||||
description: [
|
||||
"Maps properties.value to top-level value field for V1.2 clients",
|
||||
],
|
||||
affectedResources: [AffectedResource.Track],
|
||||
newSchema: TrackParamsSchema,
|
||||
oldSchema: TrackParamsV0Schema,
|
||||
|
||||
affectsRequest: true,
|
||||
affectsResponse: false,
|
||||
|
||||
// Request: V1.2 → V2.0 (extract properties.value to value if not already set)
|
||||
transformRequest: ({
|
||||
input,
|
||||
}: {
|
||||
input: z.infer<typeof TrackParamsV0Schema>;
|
||||
}): z.infer<typeof TrackParamsSchema> => {
|
||||
// Keep original value if provided, otherwise extract from properties.value
|
||||
let value = input.value;
|
||||
let properties = input.properties;
|
||||
|
||||
if (input.properties?.value !== undefined) {
|
||||
// Only use properties.value if top-level value is not set
|
||||
if (value === undefined) {
|
||||
const parsedValue = Number(input.properties.value);
|
||||
if (!Number.isNaN(parsedValue)) {
|
||||
value = parsedValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Always remove value from properties after processing
|
||||
const { value: _, ...restProperties } = input.properties;
|
||||
properties = Object.keys(restProperties).length > 0 ? restProperties : {};
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
properties,
|
||||
value,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -66,6 +66,7 @@ export * from "./balances/check/prevVersions/CheckResponseV0.js";
|
||||
export * from "./balances/check/prevVersions/CheckResponseV1.js";
|
||||
export * from "./balances/create/createBalanceParams.js";
|
||||
export * from "./balances/prevVersions/legacyUpdateBalanceModels.js";
|
||||
export * from "./balances/track/prevVersions/trackParamsV0.js";
|
||||
export * from "./balances/track/prevVersions/trackResponseV1.js";
|
||||
export * from "./balances/track/trackParams.js";
|
||||
export * from "./balances/track/trackResponseV2.js";
|
||||
|
||||
@@ -25,6 +25,7 @@ import { V0_2_CheckChange } from "../../balances/check/changes/V0.2_CheckChange.
|
||||
import { V1_2_CheckChange } from "../../balances/check/changes/V1.2_CheckChange.js";
|
||||
import { V1_2_CheckQueryChange } from "../../balances/check/changes/V1.2_CheckQueryChange.js";
|
||||
import { V1_2_TrackChange } from "../../balances/track/changes/V1.2_TrackChange.js";
|
||||
import { V1_2_TrackParamsChange } from "../../balances/track/requestChanges/V1.2_TrackParamsChange.js";
|
||||
// Import attach changes
|
||||
import { V0_2_AttachChange } from "../../billing/attach/changes/V0.2_AttachChange.js";
|
||||
import { ApiVersion } from "../ApiVersion.js";
|
||||
@@ -42,6 +43,7 @@ export const V2_CHANGES: VersionChangeConstructor[] = [
|
||||
V1_2_CheckChange, // Transforms Check TO V1.2 format from V0.2 format
|
||||
V1_2_CheckQueryChange, // Transforms Check Query TO V2.0 format (adds expand options)
|
||||
V1_2_TrackChange, // Transforms Track TO V1.2 format from V0.2 format
|
||||
V1_2_TrackParamsChange, // Transforms Track params TO V2.0 (maps properties.value → value)
|
||||
|
||||
V1_2_FeatureChange, // Transforms Feature TO V1_Beta format (V0) from V2 format (V1)
|
||||
V1_2_CreateFeatureChange, // Transforms Create Feature params TO V1_Beta
|
||||
|
||||
Reference in New Issue
Block a user