From 76792be508b2005a6ea20a49d769c388549634ba Mon Sep 17 00:00:00 2001 From: okeolaolatun23-glitch Date: Sat, 20 Jun 2026 01:31:47 +0000 Subject: [PATCH 1/2] refactor: use inArray for enrollment count query --- src/modules/courses/course.service.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index 1b5b614..fbbf0c7 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -1,4 +1,4 @@ -import { eq, and, count, desc } from "drizzle-orm"; +import { eq, and, count, desc, inArray } from "drizzle-orm"; import { db } from "../../config/database.js"; import { courses, enrollments } from "../../database/schema.js"; import { NotFoundError, ConflictError } from "../../utils/errors.js"; @@ -45,10 +45,7 @@ export class CourseService { value: count(), }) .from(enrollments) - .where( - // Using inArray would be cleaner, but we'll keep it simple - eq(enrollments.courseId, courseIds[0]) - ) + .where(inArray(enrollments.courseId, courseIds)) .groupBy(enrollments.courseId); for (const c of counts) { From 7b4c5e3276b579cd8d55f2926b54435a44c5c594 Mon Sep 17 00:00:00 2001 From: okeolaolatun23-glitch Date: Sat, 20 Jun 2026 01:50:12 +0000 Subject: [PATCH 2/2] feat: implement idempotency key system for blockchain transaction endpoints --- .../0001_create_idempotency_keys.sql | 14 ++ src/database/schema.ts | 25 ++ src/jobs/cleanup-idempotency.ts | 18 ++ src/middleware/idempotency.ts | 229 ++++++++++++++++++ .../credentials/credential.controller.ts | 5 +- src/modules/credentials/credential.service.ts | 137 ++++++++--- src/modules/credentials/credential.types.ts | 1 + src/modules/rewards/reward.controller.ts | 8 +- src/modules/rewards/reward.service.ts | 155 ++++++++---- src/modules/rewards/reward.types.ts | 1 + src/server.ts | 3 + tests/unit/services/idempotency.test.ts | 112 +++++++++ 12 files changed, 619 insertions(+), 89 deletions(-) create mode 100644 src/database/migrations/0001_create_idempotency_keys.sql create mode 100644 src/jobs/cleanup-idempotency.ts create mode 100644 src/middleware/idempotency.ts create mode 100644 tests/unit/services/idempotency.test.ts diff --git a/src/database/migrations/0001_create_idempotency_keys.sql b/src/database/migrations/0001_create_idempotency_keys.sql new file mode 100644 index 0000000..7225c75 --- /dev/null +++ b/src/database/migrations/0001_create_idempotency_keys.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS idempotency_keys ( + key VARCHAR(64) PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint VARCHAR(255) NOT NULL, + request_hash VARCHAR(64) NOT NULL, + response_status INTEGER, + response_body JSONB, + tx_hash VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_idempotency_expires ON idempotency_keys(expires_at); +CREATE INDEX IF NOT EXISTS idx_idempotency_user_endpoint ON idempotency_keys(user_id, endpoint); \ No newline at end of file diff --git a/src/database/schema.ts b/src/database/schema.ts index acefc14..246a7a9 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -131,3 +131,28 @@ export const credentials = pgTable("credentials", { .notNull() .defaultNow(), }); + +// ─── Idempotency Keys ────────────────────────────────────────────────────── + +export const idempotencyKeys = pgTable( + "idempotency_keys", + { + key: varchar("key", { length: 64 }).primaryKey(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + endpoint: varchar("endpoint", { length: 255 }).notNull(), + requestHash: varchar("request_hash", { length: 64 }).notNull(), + responseStatus: integer("response_status"), + responseBody: jsonb("response_body"), + txHash: varchar("tx_hash", { length: 64 }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + }, + (table) => [ + index("idx_idempotency_expires").on(table.expiresAt), + index("idx_idempotency_user_endpoint").on(table.userId, table.endpoint), + ] +); diff --git a/src/jobs/cleanup-idempotency.ts b/src/jobs/cleanup-idempotency.ts new file mode 100644 index 0000000..512795b --- /dev/null +++ b/src/jobs/cleanup-idempotency.ts @@ -0,0 +1,18 @@ +import { logger } from "../utils/logger.js"; +import { cleanupExpiredIdempotencyKeys } from "../middleware/idempotency.js"; + +const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export async function runIdempotencyCleanupOnce(): Promise { + return cleanupExpiredIdempotencyKeys(); +} + +export function startIdempotencyCleanupJob(): () => void { + const timer = setInterval(() => { + cleanupExpiredIdempotencyKeys().catch((err) => { + logger.error({ err }, "Failed to clean up expired idempotency keys"); + }); + }, CLEANUP_INTERVAL_MS); + + return () => clearInterval(timer); +} \ No newline at end of file diff --git a/src/middleware/idempotency.ts b/src/middleware/idempotency.ts new file mode 100644 index 0000000..8a42c7c --- /dev/null +++ b/src/middleware/idempotency.ts @@ -0,0 +1,229 @@ +import crypto from "node:crypto"; +import { and, eq, lt } from "drizzle-orm"; +import { db } from "../config/database.js"; +import { idempotencyKeys } from "../database/schema.js"; +import { ConflictError } from "../utils/errors.js"; + +const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; +const POLL_INTERVAL_MS = 250; +const MAX_WAIT_MS = 30_000; + +export interface IdempotencyRecord { + key: string; + userId: string; + endpoint: string; + requestHash: string; + responseStatus: number | null; + responseBody: unknown | null; + txHash: string | null; + createdAt: Date; + expiresAt: Date; +} + +export interface IdempotencyReservation { + state: "new" | "cached" | "resume"; + record: IdempotencyRecord; +} + +type RawIdempotencyRow = typeof idempotencyKeys.$inferSelect; + +export function hashRequestBody(requestBody: unknown): string { + return crypto + .createHash("sha256") + .update(stableStringify(requestBody)) + .digest("hex"); +} + +export async function reserveIdempotencyKey(options: { + key: string; + userId: string; + endpoint: string; + requestBody: unknown; +}): Promise { + const key = options.key.trim(); + const requestHash = hashRequestBody(options.requestBody); + + while (true) { + const existing = await loadIdempotencyKey(key); + if (existing) { + if (existing.expiresAt.getTime() <= Date.now()) { + await deleteIdempotencyKey(key); + continue; + } + + assertIdempotencyOwnership(existing, options.userId, options.endpoint); + + if (existing.requestHash !== requestHash) { + throw new ConflictError( + "Idempotency key reused with different request body" + ); + } + + if (existing.responseStatus !== null && existing.responseBody !== null) { + return { state: "cached", record: existing }; + } + + if (existing.txHash !== null) { + return { state: "resume", record: existing }; + } + + const deadline = Date.now() + MAX_WAIT_MS; + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + const latest = await loadIdempotencyKey(key); + if (!latest) { + break; + } + + assertIdempotencyOwnership(latest, options.userId, options.endpoint); + + if (latest.requestHash !== requestHash) { + throw new ConflictError( + "Idempotency key reused with different request body" + ); + } + + if (latest.responseStatus !== null && latest.responseBody !== null) { + return { state: "cached", record: latest }; + } + + if (latest.txHash !== null) { + return { state: "resume", record: latest }; + } + } + + throw new ConflictError("Idempotency key is already being processed"); + } + + try { + await db.insert(idempotencyKeys).values({ + key, + userId: options.userId, + endpoint: options.endpoint, + requestHash, + expiresAt: new Date(Date.now() + IDEMPOTENCY_TTL_MS), + }); + + const record = await loadIdempotencyKey(key); + if (!record) { + throw new Error("Failed to persist idempotency key"); + } + + return { state: "new", record }; + } catch (err) { + if (isUniqueViolation(err)) { + continue; + } + + throw err; + } + } +} + +export async function storeIdempotencyTxHash( + key: string, + txHash: string +): Promise { + await db + .update(idempotencyKeys) + .set({ txHash }) + .where(eq(idempotencyKeys.key, key)); +} + +export async function storeIdempotentResponse( + key: string, + status: number, + body: unknown +): Promise { + await db + .update(idempotencyKeys) + .set({ + responseStatus: status, + responseBody: body, + }) + .where(eq(idempotencyKeys.key, key)); +} + +export async function deleteIdempotencyKey(key: string): Promise { + await db.delete(idempotencyKeys).where(eq(idempotencyKeys.key, key)); +} + +export async function cleanupExpiredIdempotencyKeys(): Promise { + const removed = await db + .delete(idempotencyKeys) + .where(lt(idempotencyKeys.expiresAt, new Date())) + .returning({ key: idempotencyKeys.key }); + + return removed.length; +} + +function assertIdempotencyOwnership( + record: IdempotencyRecord, + userId: string, + endpoint: string +): void { + if (record.userId !== userId || record.endpoint !== endpoint) { + throw new ConflictError( + "Idempotency key is already associated with a different request" + ); + } +} + +async function loadIdempotencyKey( + key: string +): Promise { + const row = await db.query.idempotencyKeys.findFirst({ + where: eq(idempotencyKeys.key, key), + }); + + return row ? mapRow(row) : null; +} + +function mapRow(row: RawIdempotencyRow): IdempotencyRecord { + return { + key: row.key, + userId: row.userId, + endpoint: row.endpoint, + requestHash: row.requestHash, + responseStatus: row.responseStatus, + responseBody: row.responseBody, + txHash: row.txHash, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + }; +} + +function stableStringify(value: unknown): string { + if (value === null) { + return "null"; + } + + if (typeof value !== "object") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([entryKey, entryValue]) => { + return `${JSON.stringify(entryKey)}:${stableStringify(entryValue)}`; + }); + + return `{${entries.join(",")}}`; +} + +function isUniqueViolation(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: string }).code === "23505" + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} \ No newline at end of file diff --git a/src/modules/credentials/credential.controller.ts b/src/modules/credentials/credential.controller.ts index fcb9823..c1beb5f 100644 --- a/src/modules/credentials/credential.controller.ts +++ b/src/modules/credentials/credential.controller.ts @@ -13,11 +13,12 @@ export class CredentialController { reply: FastifyReply ): Promise { const { authUser } = request as AuthenticatedRequest; - const { courseId, submissionId } = (request as any).validatedBody; + const { courseId, submissionId, idempotencyKey } = (request as any).validatedBody; const result = await credentialService.mint( authUser.id, courseId, - submissionId + submissionId, + idempotencyKey ); reply.status(201).send({ success: true, data: result }); diff --git a/src/modules/credentials/credential.service.ts b/src/modules/credentials/credential.service.ts index 8d751b3..e9838f5 100644 --- a/src/modules/credentials/credential.service.ts +++ b/src/modules/credentials/credential.service.ts @@ -15,6 +15,12 @@ import { logger } from "../../utils/logger.js"; import crypto from "node:crypto"; import StellarSdk from "@stellar/stellar-sdk"; import type { MintResult, CredentialListItem } from "./credential.types.js"; +import { + deleteIdempotencyKey, + reserveIdempotencyKey, + storeIdempotentResponse, + storeIdempotencyTxHash, +} from "../../middleware/idempotency.js"; export class CredentialService { /** @@ -24,7 +30,8 @@ export class CredentialService { async mint( userId: string, courseId: string, - submissionId: string + submissionId: string, + idempotencyKey?: string ): Promise { // Verify the submission exists and belongs to the user const submission = await db.query.quizSubmissions.findFirst({ @@ -54,8 +61,22 @@ export class CredentialService { throw new ConflictError("Credential already minted for this course"); } + const idempotency = idempotencyKey + ? await reserveIdempotencyKey({ + key: idempotencyKey, + userId, + endpoint: "/api/credentials/mint", + requestBody: { courseId, submissionId, idempotencyKey }, + }) + : null; + + if (idempotency?.state === "cached") { + return idempotency.record.responseBody as MintResult; + } + + const nftAssetCode = deriveNftAssetCode(idempotencyKey ?? `${userId}:${courseId}:${submissionId}`); + // Generate NFT identifiers - const nftAssetCode = `CL${crypto.randomBytes(4).toString("hex").toUpperCase()}`; const user = await db.query.users.findFirst({ where: eq(users.id, userId), }); @@ -69,47 +90,78 @@ export class CredentialService { // Call the on-chain credential contract let txHash: string; + let onChainSucceeded = false; try { - txHash = await invokeContract( - config.STELLAR_CREDENTIAL_CONTRACT_ID, - "mint_credential", - [ - StellarSdk.Address.fromString(user.stellarAddress).toScVal(), - StellarSdk.nativeToScVal(nftAssetCode), - StellarSdk.nativeToScVal(submission.score, { type: "u32" }), - StellarSdk.nativeToScVal(Buffer.from(auth.signature, "base64")), - ] + if (idempotency?.state === "resume" && idempotency.record.txHash) { + txHash = idempotency.record.txHash; + } else { + txHash = await invokeContract( + config.STELLAR_CREDENTIAL_CONTRACT_ID, + "mint_credential", + [ + StellarSdk.Address.fromString(user.stellarAddress).toScVal(), + StellarSdk.nativeToScVal(nftAssetCode), + StellarSdk.nativeToScVal(submission.score, { type: "u32" }), + StellarSdk.nativeToScVal(Buffer.from(auth.signature, "base64")), + ] + ); + onChainSucceeded = true; + } + + if (idempotencyKey) { + await storeIdempotencyTxHash(idempotencyKey, txHash); + } + + const existingCredential = await db.query.credentials.findFirst({ + where: and( + eq(credentials.userId, userId), + eq(credentials.courseId, courseId) + ), + }); + + let credentialId = existingCredential?.id; + if (!existingCredential) { + const [credential] = await db + .insert(credentials) + .values({ + userId, + courseId, + score: submission.score, + nftAssetCode, + nftIssuer: user.stellarAddress, + mintTxHash: txHash, + }) + .returning(); + + credentialId = credential.id; + } + + const result: MintResult = { + credentialId: credentialId!, + nftAssetCode, + nftIssuer: user.stellarAddress, + mintTxHash: txHash, + message: "Course completion credential minted successfully", + }; + + if (idempotencyKey) { + await storeIdempotentResponse(idempotencyKey, 201, result); + } + + logger.info( + { credentialId: credentialId!, userId, courseId, txHash }, + "Credential minted" ); + + return result; } catch (err) { + if (!onChainSucceeded && idempotencyKey) { + await deleteIdempotencyKey(idempotencyKey); + } + logger.error({ err, userId, courseId }, "On-chain credential mint failed"); throw new Error("Failed to mint credential on-chain"); } - - // Store credential in database - const [credential] = await db - .insert(credentials) - .values({ - userId, - courseId, - score: submission.score, - nftAssetCode, - nftIssuer: user.stellarAddress, - mintTxHash: txHash, - }) - .returning(); - - logger.info( - { credentialId: credential.id, userId, courseId, txHash }, - "Credential minted" - ); - - return { - credentialId: credential.id, - nftAssetCode, - nftIssuer: user.stellarAddress, - mintTxHash: txHash, - message: "Course completion credential minted successfully", - }; } /** @@ -136,4 +188,15 @@ export class CredentialService { } } +function deriveNftAssetCode(seed: string): string { + const suffix = crypto + .createHash("sha256") + .update(seed) + .digest("hex") + .slice(0, 8) + .toUpperCase(); + + return `CL${suffix}`; +} + export const credentialService = new CredentialService(); diff --git a/src/modules/credentials/credential.types.ts b/src/modules/credentials/credential.types.ts index 216d2c5..db5092a 100644 --- a/src/modules/credentials/credential.types.ts +++ b/src/modules/credentials/credential.types.ts @@ -5,6 +5,7 @@ import { z } from "zod"; export const mintCredentialSchema = z.object({ courseId: z.string().uuid("Invalid course ID"), submissionId: z.string().uuid("Invalid submission ID"), + idempotencyKey: z.string().min(16).max(64), }); // ─── Types ────────────────────────────────────────────────────────────────── diff --git a/src/modules/rewards/reward.controller.ts b/src/modules/rewards/reward.controller.ts index dad4208..91c5e7b 100644 --- a/src/modules/rewards/reward.controller.ts +++ b/src/modules/rewards/reward.controller.ts @@ -13,8 +13,12 @@ export class RewardController { reply: FastifyReply ): Promise { const { authUser } = request as AuthenticatedRequest; - const { submissionId } = (request as any).validatedBody; - const result = await rewardService.claimReward(authUser.id, submissionId); + const { submissionId, idempotencyKey } = (request as any).validatedBody; + const result = await rewardService.claimReward( + authUser.id, + submissionId, + idempotencyKey + ); reply.send({ success: true, data: result }); } diff --git a/src/modules/rewards/reward.service.ts b/src/modules/rewards/reward.service.ts index 1437250..1271457 100644 --- a/src/modules/rewards/reward.service.ts +++ b/src/modules/rewards/reward.service.ts @@ -1,4 +1,4 @@ -import { eq, and, desc } from "drizzle-orm"; +import { eq, and, desc, sql } from "drizzle-orm"; import { db } from "../../config/database.js"; import { quizSubmissions, @@ -11,6 +11,12 @@ import { invokeContract } from "../../stellar/transactions.js"; import { createQuizProof } from "../../stellar/signatures.js"; import { config } from "../../config/index.js"; import { logger } from "../../utils/logger.js"; +import { + deleteIdempotencyKey, + reserveIdempotencyKey, + storeIdempotentResponse, + storeIdempotencyTxHash, +} from "../../middleware/idempotency.js"; import StellarSdk from "@stellar/stellar-sdk"; import type { RewardClaimResult, RewardHistoryItem } from "./reward.types.js"; @@ -23,7 +29,8 @@ export class RewardService { */ async claimReward( userId: string, - submissionId: string + submissionId: string, + idempotencyKey?: string ): Promise { const submission = await db.query.quizSubmissions.findFirst({ where: and( @@ -45,62 +52,117 @@ export class RewardService { throw new ForbiddenError("Quiz not passed — no reward available"); } - // Generate proof for the on-chain contract - const quiz = await db.query.quizzes.findFirst({ - where: eq(quizzes.id, submission.quizId), + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), }); - if (!quiz) { - throw new NotFoundError("Quiz"); + if (!user) { + throw new NotFoundError("User"); } const proof = createQuizProof(userId, submission.quizId, submission.score); + const idempotency = idempotencyKey + ? await reserveIdempotencyKey({ + key: idempotencyKey, + userId, + endpoint: "/api/rewards/claim", + requestBody: { submissionId, idempotencyKey }, + }) + : null; + + if (idempotency?.state === "cached") { + return idempotency.record.responseBody as RewardClaimResult; + } - // Invoke the reward contract on Soroban - let txHash: string; - try { - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), + const responseSubmissionId = submission.id; + const claimResultFromState = async (txHash: string): Promise => { + const latestSubmission = await db.query.quizSubmissions.findFirst({ + where: eq(quizSubmissions.id, submissionId), + }); + + if (latestSubmission?.rewardClaimed && latestSubmission.txHash === txHash) { + return { + submissionId: responseSubmissionId, + amount: REWARD_AMOUNT, + txHash, + message: `Successfully claimed ${REWARD_AMOUNT} credits`, + }; + } + + await db.transaction(async (tx) => { + const currentSubmission = await tx.query.quizSubmissions.findFirst({ + where: eq(quizSubmissions.id, submissionId), + }); + + if (!currentSubmission) { + throw new NotFoundError("Quiz submission"); + } + + if (!currentSubmission.rewardClaimed) { + await tx + .update(quizSubmissions) + .set({ rewardClaimed: true, txHash }) + .where(eq(quizSubmissions.id, submissionId)); + + await tx + .update(users) + .set({ + credits: sql`${users.credits} + ${REWARD_AMOUNT}`, + }) + .where(eq(users.id, userId)); + } }); - txHash = await invokeContract( - config.STELLAR_REWARD_CONTRACT_ID, - "claim_reward", - [ - StellarSdk.Address.fromString(user!.stellarAddress).toScVal(), - StellarSdk.nativeToScVal(submission.score, { type: "u32" }), - StellarSdk.nativeToScVal(Buffer.from(proof.signature, "base64")), - ] + return { + submissionId: responseSubmissionId, + amount: REWARD_AMOUNT, + txHash, + message: `Successfully claimed ${REWARD_AMOUNT} credits`, + }; + }; + + let txHash: string; + let onChainSucceeded = false; + try { + if (idempotency?.state === "resume" && idempotency.record.txHash) { + txHash = idempotency.record.txHash; + } else { + txHash = await invokeContract( + config.STELLAR_REWARD_CONTRACT_ID, + "claim_reward", + [ + StellarSdk.Address.fromString(user.stellarAddress).toScVal(), + StellarSdk.nativeToScVal(submission.score, { type: "u32" }), + StellarSdk.nativeToScVal(Buffer.from(proof.signature, "base64")), + ] + ); + onChainSucceeded = true; + } + + if (idempotencyKey) { + await storeIdempotencyTxHash(idempotencyKey, txHash); + } + + const result = await claimResultFromState(txHash); + + if (idempotencyKey) { + await storeIdempotentResponse(idempotencyKey, 200, result); + } + + logger.info( + { userId, submissionId, txHash, amount: REWARD_AMOUNT }, + "Reward claimed" ); + + return result; } catch (err) { + if (!onChainSucceeded && idempotencyKey) { + await deleteIdempotencyKey(idempotencyKey); + } + logger.error({ err, submissionId }, "On-chain reward claim failed"); throw new Error("Failed to process on-chain reward"); } - - // Update submission and user credits - await db - .update(quizSubmissions) - .set({ rewardClaimed: true, txHash }) - .where(eq(quizSubmissions.id, submissionId)); - - await db - .update(users) - .set({ - credits: sql`${users.credits} + ${REWARD_AMOUNT}`, - }) - .where(eq(users.id, userId)); - - logger.info( - { userId, submissionId, txHash, amount: REWARD_AMOUNT }, - "Reward claimed" - ); - - return { - submissionId, - amount: REWARD_AMOUNT, - txHash, - message: `Successfully claimed ${REWARD_AMOUNT} credits`, - }; } /** @@ -137,7 +199,4 @@ export class RewardService { } } -// Need to import sql -import { sql } from "drizzle-orm"; - export const rewardService = new RewardService(); diff --git a/src/modules/rewards/reward.types.ts b/src/modules/rewards/reward.types.ts index 1ee7d70..ed61b77 100644 --- a/src/modules/rewards/reward.types.ts +++ b/src/modules/rewards/reward.types.ts @@ -4,6 +4,7 @@ import { z } from "zod"; export const claimRewardSchema = z.object({ submissionId: z.string().uuid("Invalid submission ID"), + idempotencyKey: z.string().min(16).max(64), }); // ─── Types ────────────────────────────────────────────────────────────────── diff --git a/src/server.ts b/src/server.ts index aee4747..4233a04 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { config } from "./config/index.js"; import { logger } from "./utils/logger.js"; import { registerErrorHandler } from "./middleware/error-handler.js"; import { rateLimitOptions } from "./middleware/rate-limit.js"; +import { startIdempotencyCleanupJob } from "./jobs/cleanup-idempotency.js"; // Route modules import { authRoutes } from "./modules/auth/auth.routes.js"; @@ -68,10 +69,12 @@ async function buildApp() { async function start() { const app = await buildApp(); + const stopIdempotencyCleanup = startIdempotencyCleanupJob(); // Graceful shutdown const shutdown = async (signal: string) => { logger.info({ signal }, "Received shutdown signal"); + stopIdempotencyCleanup(); await app.close(); await closeDatabase(); await closeRedis(); diff --git a/tests/unit/services/idempotency.test.ts b/tests/unit/services/idempotency.test.ts new file mode 100644 index 0000000..c9fd534 --- /dev/null +++ b/tests/unit/services/idempotency.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockDb = { + query: { + idempotencyKeys: { + findFirst: vi.fn(), + }, + }, + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +}; + +vi.mock("../../../src/config/database.js", () => ({ + db: mockDb, +})); + +import { + hashRequestBody, + reserveIdempotencyKey, + storeIdempotentResponse, + storeIdempotencyTxHash, + cleanupExpiredIdempotencyKeys, +} from "../../../src/middleware/idempotency.js"; + +describe("idempotency helper", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("hashes semantically identical request bodies the same way", () => { + const first = hashRequestBody({ submissionId: "a", idempotencyKey: "k" }); + const second = hashRequestBody({ idempotencyKey: "k", submissionId: "a" }); + + expect(first).toBe(second); + }); + + it("returns a cached record when the same key already has a stored response", async () => { + mockDb.query.idempotencyKeys.findFirst.mockResolvedValueOnce({ + key: "test-key", + userId: "user-1", + endpoint: "/api/rewards/claim", + requestHash: hashRequestBody({ submissionId: "sub-1", idempotencyKey: "test-key" }), + responseStatus: 200, + responseBody: { success: true }, + txHash: "tx-1", + createdAt: new Date(), + expiresAt: new Date(Date.now() + 1000), + }); + + const result = await reserveIdempotencyKey({ + key: "test-key", + userId: "user-1", + endpoint: "/api/rewards/claim", + requestBody: { submissionId: "sub-1", idempotencyKey: "test-key" }, + }); + + expect(result.state).toBe("cached"); + expect(result.record.responseBody).toEqual({ success: true }); + }); + + it("rejects reuse of the same key with a different request body", async () => { + mockDb.query.idempotencyKeys.findFirst.mockResolvedValueOnce({ + key: "test-key", + userId: "user-1", + endpoint: "/api/rewards/claim", + requestHash: hashRequestBody({ submissionId: "sub-1", idempotencyKey: "test-key" }), + responseStatus: null, + responseBody: null, + txHash: null, + createdAt: new Date(), + expiresAt: new Date(Date.now() + 1000), + }); + + await expect( + reserveIdempotencyKey({ + key: "test-key", + userId: "user-1", + endpoint: "/api/rewards/claim", + requestBody: { submissionId: "sub-2", idempotencyKey: "test-key" }, + }) + ).rejects.toMatchObject({ statusCode: 409 }); + }); + + it("updates the stored tx hash and response body", async () => { + const updateSet = vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }); + + mockDb.update.mockReturnValue({ + set: updateSet, + }); + + await storeIdempotencyTxHash("test-key", "tx-1"); + await storeIdempotentResponse("test-key", 200, { success: true }); + + expect(mockDb.update).toHaveBeenCalledTimes(2); + expect(updateSet).toHaveBeenCalled(); + }); + + it("cleans up expired keys", async () => { + mockDb.delete.mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ key: "expired-key" }]), + }), + }); + + const removed = await cleanupExpiredIdempotencyKeys(); + + expect(removed).toBe(1); + }); +}); \ No newline at end of file