cpns / packages /api /src /__tests__ /queue.test.ts
rogasper's picture
feat: enhance question repair and parsing logic by introducing strict explanation checks. Update related functions to support lenient parsing options, improving flexibility in question validation. Adjust tests to verify behavior under different explanation strictness settings.
62fdec8
Raw
History Blame Contribute Delete
7.75 kB
import { describe, expect, it, beforeAll } from "bun:test";
let GenerationJobCancelledError: any,
computeSectionSplit: any,
splitIntoShards: any,
normalizeQuestions: any,
runWithConcurrency: any,
decryptInputFromDb: any;
beforeAll(async () => {
process.env.DATABASE_URL = "postgres://localhost:5432/test";
process.env.BETTER_AUTH_SECRET = "a".repeat(32);
process.env.BETTER_AUTH_URL = "http://localhost:3000";
process.env.CORS_ORIGIN = "http://localhost:5173";
process.env.API_KEY_ENCRYPTION_KEY = "z".repeat(32);
process.env.REDIS_URL = "redis://localhost:6379";
process.env.SMTP_HOST = "localhost";
process.env.SMTP_USER = "test";
process.env.SMTP_PASS = "test";
process.env.SMTP_FROM = "test@test.com";
const mod = await import("../queue");
GenerationJobCancelledError = mod.GenerationJobCancelledError;
computeSectionSplit = mod.computeSectionSplit;
splitIntoShards = mod.splitIntoShards;
normalizeQuestions = mod.normalizeQuestions;
runWithConcurrency = mod.runWithConcurrency;
decryptInputFromDb = mod.decryptInputFromDb;
});
describe("GenerationJobCancelledError", () => {
it("creates error with correct name", () => {
const err = new GenerationJobCancelledError();
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("GenerationJobCancelledError");
expect(err.message).toBe("JOB_CANCELLED");
});
});
describe("computeSectionSplit", () => {
it("returns single section when no sections selected", () => {
const result = computeSectionSplit([], 10);
expect(result).toEqual([{ section: "READING", count: 10 }]);
});
it("returns single section when count < 20 even with multiple sections", () => {
const result = computeSectionSplit(["READING", "LISTENING"], 15);
expect(result).toEqual([{ section: "READING", count: 15 }]);
});
it("distributes evenly when count >= 20 and multiple sections", () => {
const result = computeSectionSplit(["READING", "LISTENING"], 20);
expect(result).toHaveLength(2);
expect(result[0]!).toEqual({ section: "READING", count: 10 });
expect(result[1]!).toEqual({ section: "LISTENING", count: 10 });
});
it("distributes remainder (first section gets extra)", () => {
const result = computeSectionSplit(["READING", "LISTENING", "WRITING"], 22);
expect(result).toHaveLength(3);
expect(result[0]!).toEqual({ section: "READING", count: 8 }); // 7 + 1
expect(result[1]!).toEqual({ section: "LISTENING", count: 7 }); // 7 + 1
expect(result[2]!).toEqual({ section: "WRITING", count: 7 }); // 7
});
it("handles single section with high count", () => {
const result = computeSectionSplit(["READING"], 40);
expect(result).toEqual([{ section: "READING", count: 40 }]);
});
});
describe("splitIntoShards", () => {
it("creates one shard when count <= max per shard", () => {
const result = splitIntoShards([{ section: "READING", count: 5 }]);
expect(result).toHaveLength(1);
expect(result[0]!).toMatchObject({
section: "READING", count: 5, sectionIndex: 0, shardIndex: 0, shardCount: 1,
});
});
it("splits into multiple shards when count exceeds max", () => {
const result = splitIntoShards([{ section: "READING", count: 20 }]);
expect(result).toHaveLength(3); // ceil(20/8) = 3
expect(result[0]!.count).toBe(8);
expect(result[2]!.count).toBe(4); // last shard has remainder
result.forEach((shard) => {
expect(shard.section).toBe("READING");
expect(shard.shardCount).toBe(3);
});
});
it("handles multiple sections with sharding", () => {
const result = splitIntoShards([
{ section: "READING", count: 10 },
{ section: "LISTENING", count: 10 },
]);
expect(result).toHaveLength(4); // ceil(10/8) + ceil(10/8) = 2 + 2
expect(result[0]!.section).toBe("READING");
expect(result[0]!.sectionIndex).toBe(0);
expect(result[2]!.section).toBe("LISTENING");
expect(result[2]!.sectionIndex).toBe(1);
});
});
describe("normalizeQuestions", () => {
const model = "gpt-4";
it("transforms GenerationResult questions to PersistableQuestion", () => {
const result = {
questions: [{
format: "multiple_choice",
passageText: "A".repeat(50),
questionText: "Question?",
options: [{ key: "A", text: "Opt" }],
correctAnswer: "A",
explanation: "Because",
difficulty: 3,
skillTags: ["reading"],
}],
meta: { model, tokensUsed: 100, durationMs: 1000, mode: "quick" as const },
};
const normalized = normalizeQuestions("READING", model, result as any);
expect(normalized).toHaveLength(1);
expect(normalized[0]!).toMatchObject({
section: "READING",
format: "multiple_choice",
correctAnswer: "A",
difficulty: 3,
aiModel: model,
});
});
it("sets options to null when not present", () => {
const result = {
questions: [{
format: "fill_blank",
passageText: "A".repeat(50),
questionText: "Fill ___",
correctAnswer: "answer",
explanation: "explain",
difficulty: 2,
skillTags: ["grammar"],
}],
meta: { model, tokensUsed: 50, durationMs: 500, mode: "quick" as const },
};
const normalized = normalizeQuestions("READING", model, result as any);
expect(normalized[0]!.options).toBeNull();
});
it("turns off isCaseSensitive on correctAnswer when caseSensitive is false", () => {
const result = {
questions: [{
isCaseSensitive: false,
format: "fill_blank",
passageText: "A".repeat(50),
questionText: "Fill ___",
correctAnswer: "Answer",
explanation: "explain",
difficulty: 2,
skillTags: ["grammar"],
}],
meta: { model, tokensUsed: 50, durationMs: 500, mode: "quick" as const },
};
const normalized = normalizeQuestions("READING", model, result as any);
expect(normalized[0]!.correctAnswer).toBe("Answer");
});
});
describe("runWithConcurrency", () => {
it("processes all items with concurrency", async () => {
const items = [1, 2, 3, 4, 5];
const results = await runWithConcurrency(items, 3, async (item) => item * 2);
expect(results).toEqual([2, 4, 6, 8, 10]);
});
it("maintains order of results", async () => {
const items = ["a", "b", "c"];
const results = await runWithConcurrency(items, 2, async (item, idx) => `${item}-${idx}`);
expect(results).toEqual(["a-0", "b-1", "c-2"]);
});
it("handles empty array", async () => {
const results = await runWithConcurrency([], 3, async (item) => item);
expect(results).toEqual([]);
});
it("propagates errors from worker", async () => {
const items = [1, 2, 3];
expect(
runWithConcurrency(items, 2, async (item) => {
if (item === 2) throw new Error("Item 2 failed");
return item;
}),
).rejects.toThrow("Item 2 failed");
});
});
describe("decryptInputFromDb", () => {
it("passes through unencrypted apiKey (legacy data)", () => {
const input = {
examType: "IELTS",
section: "READING",
apiKeyConfig: {
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-legacy-key",
model: "gpt-4",
},
};
const result = decryptInputFromDb(input as any);
expect(result.apiKeyConfig.apiKey).toBe("sk-legacy-key");
});
it("handles missing apiKey in config", () => {
const input = {
examType: "IELTS",
section: "READING",
apiKeyConfig: {
baseUrl: "https://api.openai.com/v1",
model: "gpt-4",
},
};
const result = decryptInputFromDb(input as any);
expect(result.apiKeyConfig.apiKey).toBeUndefined();
});
});