Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions tests/unit/cache/warmer.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
51 changes: 51 additions & 0 deletions tests/unit/stellar/signatures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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);
});
});
});
98 changes: 98 additions & 0 deletions tests/unit/utils/request-context.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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<string, string | undefined> = {};

const runOne = (id: string, delayMs: number) =>
new Promise<void>((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();
});
});
});