diff --git a/drizzle/0005_add_referral_reward_allocations.sql b/drizzle/0005_add_referral_reward_allocations.sql new file mode 100644 index 0000000..fefa725 --- /dev/null +++ b/drizzle/0005_add_referral_reward_allocations.sql @@ -0,0 +1,14 @@ +CREATE TABLE "referral_reward_allocations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "referral_id" uuid NOT NULL, + "idempotency_key" text NOT NULL, + "amount" text NOT NULL, + "asset" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "referral_reward_allocations_referral_id_unique" UNIQUE("referral_id"), + CONSTRAINT "referral_reward_allocations_idempotency_key_unique" UNIQUE("idempotency_key") +); +--> statement-breakpoint +ALTER TABLE "referral_reward_allocations" ADD CONSTRAINT "referral_reward_allocations_referral_id_referrals_id_fk" FOREIGN KEY ("referral_id") REFERENCES "public"."referrals"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "referral_reward_allocations_referral_id_idx" ON "referral_reward_allocations" USING btree ("referral_id"); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 731e190..3b5aded 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,7 +103,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2488,7 +2487,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2891,7 +2889,6 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -3011,7 +3008,6 @@ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3022,7 +3018,6 @@ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -3226,7 +3221,6 @@ "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", @@ -3496,7 +3490,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4064,7 +4057,6 @@ "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "bare-path": "^3.0.0" } @@ -4291,7 +4283,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -5523,7 +5514,6 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5789,7 +5779,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -6883,7 +6872,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -7478,7 +7466,6 @@ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -8425,7 +8412,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -9999,7 +9985,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10161,7 +10146,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10865,7 +10849,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11293,7 +11276,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/db/schema.ts b/src/db/schema.ts index 88208ec..d1ce564 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -754,3 +754,36 @@ export const referrals = pgTable( export type Referral = typeof referrals.$inferSelect; export type NewReferral = typeof referrals.$inferInsert; + +// --------------------------------------------------------------------------- +// Referral reward allocations +// --------------------------------------------------------------------------- +/** + * One immutable reward allocation per referral. Both unique constraints are + * required: the referral constraint prevents double payment, while the key + * constraint makes retried requests return the original allocation. + */ +export const referralRewardAllocations = pgTable( + "referral_reward_allocations", + { + id: uuid("id").primaryKey().defaultRandom(), + referralId: uuid("referral_id") + .notNull() + .references(() => referrals.id, { onDelete: "cascade" }) + .unique(), + idempotencyKey: text("idempotency_key").notNull().unique(), + amount: text("amount").notNull(), + asset: text("asset").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + referralRewardAllocationsReferralIdIdx: index( + "referral_reward_allocations_referral_id_idx", + ).on(t.referralId), + }), +); + +export type ReferralRewardAllocation = typeof referralRewardAllocations.$inferSelect; +export type NewReferralRewardAllocation = typeof referralRewardAllocations.$inferInsert; diff --git a/src/services/referralService.ts b/src/services/referralService.ts index 8a51277..28df9df 100644 --- a/src/services/referralService.ts +++ b/src/services/referralService.ts @@ -9,7 +9,13 @@ import { eq, desc } from "drizzle-orm"; import { db } from "../db/client"; -import { referrals, type Referral, type NewReferral } from "../db/schema"; +import { + referrals, + referralRewardAllocations, + type Referral, + type NewReferral, + type ReferralRewardAllocation, +} from "../db/schema"; // --------------------------------------------------------------------------- // Public interface @@ -30,6 +36,21 @@ export interface ReferralResult { message: string; } +export interface AllocateReferralRewardInput { + referralId: string; + idempotencyKey: string; + amount: string; + asset: string; +} + +export class ReferralRewardValidationError extends Error { + readonly code = "referral_reward_validation_error"; +} + +export class ReferralRewardConflictError extends Error { + readonly code = "referral_reward_conflict"; +} + // --------------------------------------------------------------------------- // Default (production) implementations // --------------------------------------------------------------------------- @@ -78,3 +99,67 @@ export async function listUserReferrals(userId: string): Promise { .where(eq(referrals.userId, userId)) .orderBy(desc(referrals.createdAt)); } + +function validateRewardInput(input: AllocateReferralRewardInput): void { + if (!input.referralId.trim()) { + throw new ReferralRewardValidationError("referralId is required"); + } + if (!input.idempotencyKey.trim() || input.idempotencyKey.length > 128) { + throw new ReferralRewardValidationError("idempotencyKey must be 1-128 characters"); + } + if (!/^(0|[1-9]\d*)(\.\d{1,18})?$/.test(input.amount) || /^0(?:\.0{1,18})?$/.test(input.amount)) { + throw new ReferralRewardValidationError("amount must be a positive decimal with up to 18 places"); + } + if (!/^[A-Z0-9]{1,12}$/.test(input.asset)) { + throw new ReferralRewardValidationError("asset must be 1-12 uppercase alphanumeric characters"); + } +} + +function matchesAllocation( + allocation: ReferralRewardAllocation, + input: AllocateReferralRewardInput, +): boolean { + return allocation.referralId === input.referralId && + allocation.amount === input.amount && + allocation.asset === input.asset; +} + +/** + * Allocates a referral reward exactly once. The insert is the serialization + * point, so concurrent callers cannot both create an allocation. A conflict + * is safe to retry only when all business fields match the stored row. + */ +export async function allocateReferralReward( + input: AllocateReferralRewardInput, +): Promise { + validateRewardInput(input); + + const inserted = await db + .insert(referralRewardAllocations) + .values({ + referralId: input.referralId, + idempotencyKey: input.idempotencyKey, + amount: input.amount, + asset: input.asset, + }) + .onConflictDoNothing() + .returning(); + if (inserted[0]) return inserted[0]; + + const byKey = await db + .select() + .from(referralRewardAllocations) + .where(eq(referralRewardAllocations.idempotencyKey, input.idempotencyKey)); + const byReferral = byKey[0] ?? (await db + .select() + .from(referralRewardAllocations) + .where(eq(referralRewardAllocations.referralId, input.referralId)))[0]; + + if (!byReferral) { + throw new Error("referral reward allocation conflict could not be resolved"); + } + if (!matchesAllocation(byReferral, input)) { + throw new ReferralRewardConflictError("referral reward allocation does not match the existing allocation"); + } + return byReferral; +} diff --git a/tests/referralRewardAllocations.test.ts b/tests/referralRewardAllocations.test.ts new file mode 100644 index 0000000..fea6b82 --- /dev/null +++ b/tests/referralRewardAllocations.test.ts @@ -0,0 +1,703 @@ +/** + * Unit tests for referral reward allocation idempotency. + * + * This test suite validates that allocateReferralReward implements strict + * idempotent semantics: + * - First allocation succeeds and is persisted durably. + * - Exact retry with same idempotency key returns the stored allocation. + * - Retry with different reward parameters is rejected with explicit conflict. + * - Concurrent/race inserts on the same referral or key are serialized safely. + * - Validation errors are consistent and safe to expose to clients. + * + * All database access is mocked so no real state is modified. + */ + +// ── Environment stubs (must be set before any module import) ───────────────── +process.env.JWT_SECRET = "test-jwt-secret-at-least-32-bytes-long-000000"; +process.env.DATABASE_URL = "postgres://postgres:postgres@localhost:5432/predictify"; +process.env.SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org"; +process.env.HORIZON_URL = "https://horizon-testnet.stellar.org"; +process.env.PREDICTIFY_CONTRACT_ID = "CABCDEF"; + +// ── Mock pg and drizzle before any imports ───────────────────────────────── +jest.mock("pg", () => { + const Pool = jest.fn().mockImplementation(() => ({ + connect: jest.fn(), + query: jest.fn(), + end: jest.fn(), + on: jest.fn(), + })); + return { Pool }; +}); + +jest.mock("drizzle-orm/node-postgres", () => ({ + drizzle: jest.fn(() => ({})), +})); + +// ── Mock db.client with controllable mock ───────────────────────────────── + +let mockDb: any = {}; + +jest.mock("../src/db/client", () => ({ + get db() { + return mockDb; + }, +})); + +import { + allocateReferralReward, + ReferralRewardValidationError, + ReferralRewardConflictError, + type AllocateReferralRewardInput, +} from "../src/services/referralService"; +import type { ReferralRewardAllocation } from "../src/db/schema"; + +// ── Test fixtures ──────────────────────────────────────────────────────────── + +const VALID_REFERRAL_ID = "00000000-0000-0000-0000-000000000001"; +const VALID_IDEMPOTENCY_KEY = "req-2026-08-29-001"; +const VALID_AMOUNT = "100.5"; +const VALID_ASSET = "USDC"; + +function makeAllocation(overrides: Partial = {}): ReferralRewardAllocation { + return { + id: "00000000-0000-0000-0000-000000000100", + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + createdAt: new Date("2026-08-29T12:00:00.000Z"), + ...overrides, + }; +} + +function makeMockInsert() { + return { + values: jest.fn(function () { + return { + onConflictDoNothing: jest.fn(function () { + return { + returning: jest.fn(), + }; + }), + }; + }), + }; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("allocateReferralReward - Validation", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("rejects empty referralId", async () => { + await expect( + allocateReferralReward({ + referralId: "", + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }), + ).rejects.toThrow(ReferralRewardValidationError); + }); + + it("rejects whitespace-only referralId", async () => { + await expect( + allocateReferralReward({ + referralId: " ", + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }), + ).rejects.toThrow(ReferralRewardValidationError); + }); + + it("rejects empty idempotencyKey", async () => { + await expect( + allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: "", + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }), + ).rejects.toThrow(ReferralRewardValidationError); + }); + + it("rejects idempotencyKey exceeding 128 characters", async () => { + const longKey = "k".repeat(129); + await expect( + allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: longKey, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }), + ).rejects.toThrow(ReferralRewardValidationError); + }); + + it("accepts idempotencyKey at exactly 128 characters", async () => { + const key128 = "k".repeat(128); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => [makeAllocation({ idempotencyKey: key128 })]), + })), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: key128, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }); + + expect(result.idempotencyKey).toBe(key128); + }); + + it("rejects negative amount", async () => { + await expect( + allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: "-100.5", + asset: VALID_ASSET, + }), + ).rejects.toThrow(ReferralRewardValidationError); + }); + + it("rejects zero amount", async () => { + await expect( + allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: "0", + asset: VALID_ASSET, + }), + ).rejects.toThrow(ReferralRewardValidationError); + }); + + it("rejects amount with too many decimal places (> 18)", async () => { + await expect( + allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: "100.123456789012345678901", + asset: VALID_ASSET, + }), + ).rejects.toThrow(ReferralRewardValidationError); + }); + + it("accepts amount with exactly 18 decimal places", async () => { + const amount = "100.123456789012345678"; + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => [makeAllocation({ amount })]), + })), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount, + asset: VALID_ASSET, + }); + + expect(result.amount).toBe(amount); + }); + + it("accepts whole number without decimal point", async () => { + const amount = "100"; + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => [makeAllocation({ amount })]), + })), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount, + asset: VALID_ASSET, + }); + + expect(result.amount).toBe(amount); + }); + + it("rejects asset with lowercase letters", async () => { + await expect( + allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: "usdc", + }), + ).rejects.toThrow(ReferralRewardValidationError); + }); + + it("rejects asset exceeding 12 characters", async () => { + await expect( + allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: "A".repeat(13), + }), + ).rejects.toThrow(ReferralRewardValidationError); + }); + + it("accepts asset with exactly 12 characters", async () => { + const asset = "A".repeat(12); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => [makeAllocation({ asset })]), + })), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset, + }); + + expect(result.asset).toBe(asset); + }); + + it("accepts numeric-only asset", async () => { + const asset = "0123456789"; + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => [makeAllocation({ asset })]), + })), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset, + }); + + expect(result.asset).toBe(asset); + }); +}); + +describe("allocateReferralReward - First allocation (no conflict)", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("succeeds and returns the inserted allocation", async () => { + const allocation = makeAllocation(); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => [allocation]), + })), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }); + + expect(result).toEqual(allocation); + expect(mockDb.insert).toHaveBeenCalled(); + }); + + it("passes correct values to database insert", async () => { + const allocation = makeAllocation(); + const mockValues = jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => [allocation]), + })), + })); + mockDb.insert = jest.fn(() => ({ values: mockValues })); + + await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }); + + expect(mockValues).toHaveBeenCalledWith({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }); + }); +}); + +describe("allocateReferralReward - Exact retry (idempotency)", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns existing allocation when idempotency key matches", async () => { + const allocation = makeAllocation(); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => []), // insert returns nothing (conflict) + })), + })), + })); + mockDb.select = jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(async () => [allocation]), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }); + + expect(result).toEqual(allocation); + }); + + it("queries by idempotency key first when insert fails", async () => { + const allocation = makeAllocation(); + const mockSelect = jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(async () => [allocation]), + })), + })); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => []), + })), + })), + })); + mockDb.select = mockSelect; + + await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }); + + expect(mockSelect).toHaveBeenCalled(); + }); +}); + +describe("allocateReferralReward - Mismatch detection (safety)", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("rejects when idempotency key exists but referralId differs", async () => { + const stored = makeAllocation({ + referralId: "different-referral-id", + }); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => []), + })), + })), + })); + mockDb.select = jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(async () => [stored]), + })), + })); + + await expect( + allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }), + ).rejects.toThrow(ReferralRewardConflictError); + }); + + it("rejects when idempotency key exists but amount differs", async () => { + const stored = makeAllocation({ + amount: "999.99", + }); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => []), + })), + })), + })); + mockDb.select = jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(async () => [stored]), + })), + })); + + await expect( + allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }), + ).rejects.toThrow(ReferralRewardConflictError); + }); + + it("rejects when idempotency key exists but asset differs", async () => { + const stored = makeAllocation({ + asset: "NATIVE", + }); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => []), + })), + })), + })); + mockDb.select = jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(async () => [stored]), + })), + })); + + await expect( + allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }), + ).rejects.toThrow(ReferralRewardConflictError); + }); + + it("error message does not expose internal state", async () => { + const stored = makeAllocation({ amount: "999.99" }); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => []), + })), + })), + })); + mockDb.select = jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(async () => [stored]), + })), + })); + + try { + await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }); + fail("Expected error to be thrown"); + } catch (error: any) { + expect(error.code).toBe("referral_reward_conflict"); + expect(error.message).not.toContain("999.99"); // should not leak stored value + expect(error.message).not.toContain(VALID_AMOUNT); // should not leak request value + } + }); +}); + +describe("allocateReferralReward - Referral uniqueness (no double pay)", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns existing allocation when referral has allocation with different key but same reward params", async () => { + const existing = makeAllocation({ + idempotencyKey: "req-2026-08-29-old", + }); + let selectCallCount = 0; + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => []), + })), + })), + })); + mockDb.select = jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(async () => { + selectCallCount++; + // First call is by key (returns nothing), second call is by referral + return selectCallCount === 1 ? [] : [existing]; + }), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: "req-2026-08-29-new", // different key + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }); + + // Returns the existing allocation even with different key, because referral is already allocated + expect(result).toEqual(existing); + }); + + it("allows same referral + same key (idempotent retry)", async () => { + const allocation = makeAllocation(); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => []), + })), + })), + })); + mockDb.select = jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(async () => [allocation]), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }); + + expect(result).toEqual(allocation); + }); +}); + +describe("allocateReferralReward - Boundary cases", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("handles very large amount", async () => { + const largeAmount = "999999999999999999.999999999999999999"; + const allocation = makeAllocation({ amount: largeAmount }); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => [allocation]), + })), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: largeAmount, + asset: VALID_ASSET, + }); + + expect(result.amount).toBe(largeAmount); + }); + + it("handles minimum positive amount (0.000000000000000001)", async () => { + const minAmount = "0.000000000000000001"; + const allocation = makeAllocation({ amount: minAmount }); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => [allocation]), + })), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: minAmount, + asset: VALID_ASSET, + }); + + expect(result.amount).toBe(minAmount); + }); + + it("handles single-character asset", async () => { + const asset = "X"; + const allocation = makeAllocation({ asset }); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => [allocation]), + })), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset, + }); + + expect(result.asset).toBe(asset); + }); + + it("handles key at minimum length (1 character)", async () => { + const key = "k"; + const allocation = makeAllocation({ idempotencyKey: key }); + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => [allocation]), + })), + })), + })); + + const result = await allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: key, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }); + + expect(result.idempotencyKey).toBe(key); + }); +}); + +describe("allocateReferralReward - Error cases", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("throws generic error if neither key nor referral lookup resolves", async () => { + mockDb.insert = jest.fn(() => ({ + values: jest.fn(() => ({ + onConflictDoNothing: jest.fn(() => ({ + returning: jest.fn(async () => []), + })), + })), + })); + mockDb.select = jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(async () => []), + })), + })); + + await expect( + allocateReferralReward({ + referralId: VALID_REFERRAL_ID, + idempotencyKey: VALID_IDEMPOTENCY_KEY, + amount: VALID_AMOUNT, + asset: VALID_ASSET, + }), + ).rejects.toThrow("referral reward allocation conflict could not be resolved"); + }); +});