From f2e31249dd8e0322b373f2efe97013406a2b0cd9 Mon Sep 17 00:00:00 2001 From: Ejere Date: Wed, 21 Oct 2026 07:54:31 +0100 Subject: [PATCH 1/3] test(cache): add unit tests for cache warmer (#584) Cover warmCourseCache()'s pagination (single page, multiple pages, zero courses), the userId=null contract, success logging, and error handling for both the initial probe fetch and a later page in the warming loop. --- tests/unit/cache/warmer.test.ts | 120 ++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/unit/cache/warmer.test.ts diff --git a/tests/unit/cache/warmer.test.ts b/tests/unit/cache/warmer.test.ts new file mode 100644 index 0000000..9ba9204 --- /dev/null +++ b/tests/unit/cache/warmer.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +vi.mock("../../../src/utils/request-context.js", () => ({ + getRequestId: vi.fn(() => "req-123"), +})); + +const { mockListCourses } = vi.hoisted(() => ({ mockListCourses: vi.fn() })); +vi.mock("../../../src/modules/courses/course.service.js", () => ({ + courseService: { listCourses: mockListCourses }, +})); + +import { warmCourseCache } from "../../../src/cache/warmer.js"; +import { logger } from "../../../src/utils/logger.js"; + +describe("warmCourseCache", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("warms page 1 (including the initial probe fetch) when the total fits within a single page", async () => { + mockListCourses.mockResolvedValue({ courses: [], total: 5 }); + + await warmCourseCache(); + + // The initial fetch (to learn `total`) and the page-1 loop iteration + // both request page 1 — this is the function's actual behavior, not a + // regression; the fetch is cheap and the page is served from cache the + // second time. + expect(mockListCourses).toHaveBeenCalledTimes(2); + for (const call of mockListCourses.mock.calls) { + expect(call).toEqual([null, { page: 1, limit: 20 }]); + } + }); + + it("warms every page of the listing, not just page 1", async () => { + // total=45 at limit=20 => 3 pages (20, 20, 5), plus the initial probe fetch of page 1 + mockListCourses.mockResolvedValue({ courses: [], total: 45 }); + + await warmCourseCache(); + + expect(mockListCourses).toHaveBeenCalledTimes(4); + expect(mockListCourses).toHaveBeenNthCalledWith(1, null, { page: 1, limit: 20 }); // initial probe + expect(mockListCourses).toHaveBeenNthCalledWith(2, null, { page: 1, limit: 20 }); // loop page 1 + expect(mockListCourses).toHaveBeenNthCalledWith(3, null, { page: 2, limit: 20 }); + expect(mockListCourses).toHaveBeenNthCalledWith(4, null, { page: 3, limit: 20 }); + }); + + it("warms at least one page even when there are zero courses", async () => { + mockListCourses.mockResolvedValue({ courses: [], total: 0 }); + + await warmCourseCache(); + + expect(mockListCourses).toHaveBeenCalledTimes(2); + for (const call of mockListCourses.mock.calls) { + expect(call).toEqual([null, { page: 1, limit: 20 }]); + } + }); + + it("calls listCourses with userId=null so warming bypasses any per-user enrollment data", async () => { + mockListCourses.mockResolvedValue({ courses: [], total: 10 }); + + await warmCourseCache(); + + for (const call of mockListCourses.mock.calls) { + expect(call[0]).toBeNull(); + } + }); + + it("logs a success message with the number of pages warmed", async () => { + mockListCourses.mockResolvedValue({ courses: [], total: 45 }); + + await warmCourseCache(); + + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ requestId: "req-123", pagesWarmed: 3 }), + expect.stringContaining("successfully warmed"), + ); + }); + + it("catches errors from the initial listCourses call and logs them without throwing", async () => { + mockListCourses.mockRejectedValueOnce(new Error("db unavailable")); + + await expect(warmCourseCache()).resolves.toBeUndefined(); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.any(Error), + requestId: "req-123", + }), + expect.stringContaining("failed"), + ); + }); + + it("catches errors raised partway through the warming loop and logs them without throwing", async () => { + // First call is the initial probe fetch (succeeds); second call is the + // loop's page-1 iteration, which fails. + mockListCourses + .mockResolvedValueOnce({ courses: [], total: 45 }) + .mockRejectedValueOnce(new Error("timeout warming page 1")); + + await expect(warmCourseCache()).resolves.toBeUndefined(); + + // Stopped after the failure — did not attempt pages 2 or 3. + expect(mockListCourses).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Error) }), + expect.stringContaining("failed"), + ); + }); + + it("never rejects, regardless of failure mode", async () => { + mockListCourses.mockRejectedValue("non-Error rejection value"); + + await expect(warmCourseCache()).resolves.toBeUndefined(); + }); +}); From 3292e684d9eea168aca752e55eb94c6d0251efba Mon Sep 17 00:00:00 2001 From: Ejere Date: Wed, 21 Oct 2026 07:54:36 +0100 Subject: [PATCH 2/3] test(utils): add unit tests for request context utility (#585) Cover runWithRequestContext/getRequestId's propagation to async operations and microtasks, isolation between sequential and concurrent contexts, nested-context resolution, and behavior outside of any context. --- tests/unit/utils/request-context.test.ts | 98 ++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/unit/utils/request-context.test.ts diff --git a/tests/unit/utils/request-context.test.ts b/tests/unit/utils/request-context.test.ts new file mode 100644 index 0000000..350b3a5 --- /dev/null +++ b/tests/unit/utils/request-context.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { + runWithRequestContext, + getRequestId, +} from "../../../src/utils/request-context.js"; + +describe("request-context", () => { + describe("runWithRequestContext / getRequestId", () => { + it("propagates the requestId to code running inside the callback", () => { + let seen: string | undefined; + runWithRequestContext("req-abc", () => { + seen = getRequestId(); + }); + expect(seen).toBe("req-abc"); + }); + + it("returns undefined outside of any request context", () => { + expect(getRequestId()).toBeUndefined(); + }); + + it("propagates the requestId across an async operation inside the context", async () => { + let seen: string | undefined; + await new Promise((resolve) => { + runWithRequestContext("req-async", () => { + setTimeout(() => { + seen = getRequestId(); + resolve(); + }, 0); + }); + }); + expect(seen).toBe("req-async"); + }); + + it("propagates the requestId across a microtask (Promise.resolve) inside the context", async () => { + let seen: string | undefined; + await new Promise((resolve) => { + runWithRequestContext("req-microtask", () => { + Promise.resolve().then(() => { + seen = getRequestId(); + resolve(); + }); + }); + }); + expect(seen).toBe("req-microtask"); + }); + + it("isolates requestId between sequential, non-nested contexts", () => { + const seen: (string | undefined)[] = []; + runWithRequestContext("req-1", () => { + seen.push(getRequestId()); + }); + runWithRequestContext("req-2", () => { + seen.push(getRequestId()); + }); + expect(seen).toEqual(["req-1", "req-2"]); + expect(getRequestId()).toBeUndefined(); + }); + + it("resolves to the innermost requestId within a nested context", () => { + const seen: (string | undefined)[] = []; + runWithRequestContext("outer", () => { + seen.push(getRequestId()); + runWithRequestContext("inner", () => { + seen.push(getRequestId()); + }); + // Back in the outer context after the nested call returns. + seen.push(getRequestId()); + }); + expect(seen).toEqual(["outer", "inner", "outer"]); + }); + + it("isolates requestId between concurrently-running async contexts", async () => { + const results: Record = {}; + + const runOne = (id: string, delayMs: number) => + new Promise((resolve) => { + runWithRequestContext(id, () => { + setTimeout(() => { + results[id] = getRequestId(); + resolve(); + }, delayMs); + }); + }); + + await Promise.all([runOne("req-x", 10), runOne("req-y", 5)]); + + expect(results["req-x"]).toBe("req-x"); + expect(results["req-y"]).toBe("req-y"); + }); + + it("does not leak the requestId after the callback has returned", () => { + runWithRequestContext("req-leak-check", () => { + /* no-op */ + }); + expect(getRequestId()).toBeUndefined(); + }); + }); +}); From 81b9ee67a83277f79399d5b72c46888ac2a5f3a0 Mon Sep 17 00:00:00 2001 From: Ejere Date: Wed, 21 Oct 2026 07:54:42 +0100 Subject: [PATCH 3/3] test(stellar): extend signature tests with nonce/timestamp/hash coverage (#587) createQuizProof and createMintAuthorization already had tests covering the happy path; add coverage for nonce uniqueness (what makes two calls with identical business inputs produce different signatures), ISO timestamp format, and the hash's hex/length shape. --- tests/unit/stellar/signatures.test.ts | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/unit/stellar/signatures.test.ts b/tests/unit/stellar/signatures.test.ts index 00253ab..6b2f944 100644 --- a/tests/unit/stellar/signatures.test.ts +++ b/tests/unit/stellar/signatures.test.ts @@ -36,6 +36,33 @@ describe("Stellar Signatures", () => { expect(proof1.hash).not.toBe(proof2.hash); }); + + it("should include a nonce and timestamp, and generate a fresh nonce on every call", () => { + const proof1 = createQuizProof("GUSER123", "quiz-1", 85); + const proof2 = createQuizProof("GUSER123", "quiz-1", 85); + + expect(typeof proof1.nonce).toBe("string"); + expect(proof1.nonce.length).toBeGreaterThan(0); + expect(proof1.nonce).not.toBe(proof2.nonce); + // Nonce uniqueness is what makes two calls with identical business + // inputs produce different hashes/signatures (single-use proofs). + expect(proof1.hash).not.toBe(proof2.hash); + + expect(typeof proof1.timestamp).toBe("string"); + expect(() => new Date(proof1.timestamp)).not.toThrow(); + expect(new Date(proof1.timestamp).toISOString()).toBe(proof1.timestamp); + }); + + it("should produce a 64-character hex SHA-256 hash", () => { + const proof = createQuizProof("GUSER123", "quiz-1", 85); + expect(proof.hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("should produce a different hash when only the score differs", () => { + const proof1 = createQuizProof("GUSER123", "quiz-1", 85); + const proof2 = createQuizProof("GUSER123", "quiz-1", 90); + expect(proof1.hash).not.toBe(proof2.hash); + }); }); describe("createMintAuthorization", () => { @@ -48,5 +75,29 @@ describe("Stellar Signatures", () => { expect(typeof auth.signature).toBe("string"); expect(mockKeypair.sign).toHaveBeenCalled(); }); + + it("should include a nonce and timestamp, and generate a fresh nonce on every call", () => { + const auth1 = createMintAuthorization("GUSER123", "course-1", 90); + const auth2 = createMintAuthorization("GUSER123", "course-1", 90); + + expect(typeof auth1.nonce).toBe("string"); + expect(auth1.nonce.length).toBeGreaterThan(0); + expect(auth1.nonce).not.toBe(auth2.nonce); + expect(auth1.hash).not.toBe(auth2.hash); + + expect(typeof auth1.timestamp).toBe("string"); + expect(new Date(auth1.timestamp).toISOString()).toBe(auth1.timestamp); + }); + + it("should produce a 64-character hex SHA-256 hash", () => { + const auth = createMintAuthorization("GUSER123", "course-1", 90); + expect(auth.hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("should produce a different hash than a quiz proof for the same user/score (different action discriminator)", () => { + const quizProof = createQuizProof("GUSER123", "course-1", 90); + const mintAuth = createMintAuthorization("GUSER123", "course-1", 90); + expect(quizProof.hash).not.toBe(mintAuth.hash); + }); }); });