Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Pencere kontrolü saf saat aritmetiği, hiç I/O yapmıyor ve pencere dışındaki iş zaten hiçbir faydalı iş yapamıyor — dolayısıyla dakikalık Redis sayacından da önce gelmeli. Yeni sıra: pencere → cooldown → dakikalık tavan → günlük bütçe. Böylece pencere dışında uyanan iş hiçbir sayacı kirletmiyor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
287 lines
9.7 KiB
TypeScript
287 lines
9.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
/**
|
||
* Regression lock for the PL24 budget/window DEADLOCK (plv2.md, consumers_jobs-03).
|
||
*
|
||
* Two bugs combined to kill PL24 prefetch outright on prod:
|
||
* 1. `checkSourceDailyBudget` was debited in `process()` while the
|
||
* business-hours gate still sat at the top of each handler — so a job that
|
||
* woke outside the window paid a budget unit to do nothing.
|
||
* 2. A spent source was deferred to the next UTC midnight, which is 03:00
|
||
* Europe/Istanbul — six hours BEFORE a 09:00 window opens. Every deferred
|
||
* job therefore woke early, burned a unit of the fresh daily budget, hit
|
||
* the window gate and was deferred again.
|
||
*
|
||
* Measured on prod 2026-09-20 before the fix: `prefetch:daily:pl24` at 600/600
|
||
* with ZERO catalog requests and ZERO new pl24 categories for the whole day.
|
||
*
|
||
* The window constants are read at module load, so every case imports the
|
||
* modules fresh with the env already set.
|
||
*/
|
||
|
||
const REAL_ENV = { ...process.env };
|
||
|
||
/** 2026-09-20 00:00 UTC = 03:00 Europe/Istanbul — outside a 09:00–18:00 window. */
|
||
const OUTSIDE_MS = Date.UTC(2026, 8, 20, 0, 0, 0);
|
||
/** 2026-09-20 09:00 UTC = 12:00 Europe/Istanbul — inside it. */
|
||
const INSIDE_MS = Date.UTC(2026, 8, 20, 9, 0, 0);
|
||
|
||
function istanbulHour(ms: number): number {
|
||
return (
|
||
Number.parseInt(
|
||
new Intl.DateTimeFormat("en-US", {
|
||
timeZone: "Europe/Istanbul",
|
||
hour: "numeric",
|
||
hour12: false,
|
||
}).format(new Date(ms)),
|
||
10,
|
||
) % 24
|
||
);
|
||
}
|
||
|
||
async function loadModules(env: Record<string, string | undefined>) {
|
||
vi.resetModules();
|
||
for (const [k, v] of Object.entries(env)) {
|
||
if (v === undefined) delete process.env[k];
|
||
else process.env[k] = v;
|
||
}
|
||
const utils = await import("./prefetch-utils");
|
||
const worker = await import("./prefetch-worker.service");
|
||
return { utils, worker };
|
||
}
|
||
|
||
/** Minimal deps: only what `process()` touches before dispatching a job. */
|
||
function makeDeps(redisOverrides: Record<string, unknown> = {}) {
|
||
const incrCalls: string[] = [];
|
||
const redis = {
|
||
exists: vi.fn(async (..._a: unknown[]) => false),
|
||
get: vi.fn(async (..._a: unknown[]): Promise<string | null> => null),
|
||
set: vi.fn(async (..._a: unknown[]) => undefined),
|
||
del: vi.fn(async (..._a: unknown[]) => undefined),
|
||
incr: vi.fn(async (k: string) => {
|
||
incrCalls.push(k);
|
||
return 1;
|
||
}),
|
||
expire: vi.fn(async (..._a: unknown[]) => undefined),
|
||
ttl: vi.fn(async (..._a: unknown[]) => -2), // no cooldown key
|
||
setNx: vi.fn(async (..._a: unknown[]) => true),
|
||
getJson: vi.fn(async (..._a: unknown[]): Promise<unknown> => null),
|
||
setJson: vi.fn(async (..._a: unknown[]) => undefined),
|
||
...redisOverrides,
|
||
};
|
||
const queue = {
|
||
name: "catalog-prefetch",
|
||
add: vi.fn(async (..._a: unknown[]) => undefined),
|
||
getJob: vi.fn(async (..._a: unknown[]): Promise<unknown> => null),
|
||
};
|
||
const categoriesService = {
|
||
getCategoryWithParts: vi.fn(async (..._a: unknown[]) => ({ parts: [] })),
|
||
getChildren: vi.fn(async (..._a: unknown[]) => []),
|
||
};
|
||
return { redis, queue, categoriesService, incrCalls };
|
||
}
|
||
|
||
function makeJob(name: string, data: Record<string, unknown>) {
|
||
return {
|
||
name,
|
||
data,
|
||
moveToDelayed: vi.fn(async (..._a: unknown[]) => undefined),
|
||
attemptsMade: 0,
|
||
};
|
||
}
|
||
|
||
const dailyIncrs = (keys: string[]) => keys.filter((k) => k.startsWith("prefetch:daily:pl24"));
|
||
|
||
describe("alignToWindow", () => {
|
||
afterEach(() => {
|
||
process.env = { ...REAL_ENV };
|
||
});
|
||
|
||
it("pushes a pre-window instant into the configured window", async () => {
|
||
const { utils } = await loadModules({ PREFETCH_PL24_START: "9", PREFETCH_PL24_END: "18" });
|
||
const aligned = utils.alignToWindow("pl24", OUTSIDE_MS);
|
||
expect(aligned).toBeGreaterThan(OUTSIDE_MS);
|
||
const h = istanbulHour(aligned);
|
||
expect(h).toBeGreaterThanOrEqual(9);
|
||
expect(h).toBeLessThan(18);
|
||
});
|
||
|
||
it("leaves an in-window instant untouched", async () => {
|
||
const { utils } = await loadModules({ PREFETCH_PL24_START: "9", PREFETCH_PL24_END: "18" });
|
||
expect(utils.alignToWindow("pl24", INSIDE_MS)).toBe(INSIDE_MS);
|
||
});
|
||
|
||
it("is a no-op for sources that have no window", async () => {
|
||
const { utils } = await loadModules({ PREFETCH_PL24_START: "9", PREFETCH_PL24_END: "18" });
|
||
expect(utils.alignToWindow("emex", OUTSIDE_MS)).toBe(OUTSIDE_MS);
|
||
expect(utils.alignToWindow("parts-catalogs", OUTSIDE_MS)).toBe(OUTSIDE_MS);
|
||
});
|
||
|
||
it("is a no-op when the window is disabled (the default 0–24)", async () => {
|
||
const { utils } = await loadModules({
|
||
PREFETCH_PL24_START: undefined,
|
||
PREFETCH_PL24_END: undefined,
|
||
});
|
||
expect(utils.alignToWindow("pl24", OUTSIDE_MS)).toBe(OUTSIDE_MS);
|
||
});
|
||
});
|
||
|
||
describe("process() gate order — window before daily budget", () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers();
|
||
});
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
process.env = { ...REAL_ENV };
|
||
});
|
||
|
||
it("does NOT debit the daily budget for a job deferred by the window", async () => {
|
||
const { worker } = await loadModules({
|
||
PREFETCH_PL24_START: "9",
|
||
PREFETCH_PL24_END: "18",
|
||
PL24_TR_DISABLED: undefined,
|
||
});
|
||
vi.setSystemTime(OUTSIDE_MS);
|
||
const { redis, queue, categoriesService, incrCalls } = makeDeps();
|
||
const svc = new worker.PrefetchWorkerService(
|
||
queue as never,
|
||
queue as never,
|
||
categoriesService as never,
|
||
redis as never,
|
||
{ payload: vi.fn(async () => ({})) } as never,
|
||
{} as never,
|
||
);
|
||
const job = makeJob("prefetch-parts", {
|
||
vehicleId: "v1",
|
||
categoryId: "c1",
|
||
source: "pl24",
|
||
fast: true,
|
||
});
|
||
|
||
await expect(
|
||
(svc as never as { process: (j: unknown, t?: string) => Promise<void> }).process(job, "tok"),
|
||
).rejects.toThrow();
|
||
|
||
expect(job.moveToDelayed).toHaveBeenCalled();
|
||
expect(dailyIncrs(incrCalls)).toHaveLength(0);
|
||
// The per-minute counter is not charged either: the window gate is pure
|
||
// clock arithmetic and runs before any Redis write.
|
||
expect(incrCalls).toHaveLength(0);
|
||
// …and the handler never ran, so nothing was fetched upstream either.
|
||
expect(categoriesService.getCategoryWithParts).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("defers the window-blocked job to an instant inside the window", async () => {
|
||
const { worker } = await loadModules({
|
||
PREFETCH_PL24_START: "9",
|
||
PREFETCH_PL24_END: "18",
|
||
PL24_TR_DISABLED: undefined,
|
||
});
|
||
vi.setSystemTime(OUTSIDE_MS);
|
||
const { redis, queue, categoriesService } = makeDeps();
|
||
const svc = new worker.PrefetchWorkerService(
|
||
queue as never,
|
||
queue as never,
|
||
categoriesService as never,
|
||
redis as never,
|
||
{ payload: vi.fn(async () => ({})) } as never,
|
||
{} as never,
|
||
);
|
||
const job = makeJob("prefetch-parts", {
|
||
vehicleId: "v1",
|
||
categoryId: "c1",
|
||
source: "pl24",
|
||
fast: true,
|
||
});
|
||
await expect(
|
||
(svc as never as { process: (j: unknown, t?: string) => Promise<void> }).process(job, "tok"),
|
||
).rejects.toThrow();
|
||
|
||
const [when] = job.moveToDelayed.mock.calls[0] as [number];
|
||
const h = istanbulHour(when);
|
||
expect(h).toBeGreaterThanOrEqual(9);
|
||
expect(h).toBeLessThan(18);
|
||
});
|
||
|
||
it("debits the daily budget once the window is open", async () => {
|
||
const { worker } = await loadModules({
|
||
PREFETCH_PL24_START: "9",
|
||
PREFETCH_PL24_END: "18",
|
||
PL24_TR_DISABLED: undefined,
|
||
});
|
||
vi.setSystemTime(INSIDE_MS);
|
||
const { redis, queue, categoriesService, incrCalls } = makeDeps();
|
||
const svc = new worker.PrefetchWorkerService(
|
||
queue as never,
|
||
queue as never,
|
||
categoriesService as never,
|
||
redis as never,
|
||
{ payload: vi.fn(async () => ({})) } as never,
|
||
{} as never,
|
||
);
|
||
const job = makeJob("prefetch-parts", {
|
||
vehicleId: "v1",
|
||
categoryId: "c1",
|
||
source: "pl24",
|
||
fast: true,
|
||
});
|
||
|
||
await (svc as never as { process: (j: unknown, t?: string) => Promise<void> }).process(
|
||
job,
|
||
"tok",
|
||
);
|
||
|
||
expect(dailyIncrs(incrCalls)).toHaveLength(1);
|
||
expect(categoriesService.getCategoryWithParts).toHaveBeenCalledWith("c1");
|
||
});
|
||
});
|
||
|
||
describe("checkSourceDailyBudget — spent source retries inside the window", () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers();
|
||
});
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
process.env = { ...REAL_ENV };
|
||
});
|
||
|
||
it("never parks a spent source at 03:00 Istanbul again", async () => {
|
||
const { worker } = await loadModules({
|
||
PREFETCH_PL24_START: "9",
|
||
PREFETCH_PL24_END: "18",
|
||
PREFETCH_DAILY_PL24: "600",
|
||
});
|
||
vi.setSystemTime(INSIDE_MS);
|
||
// Counter already at the fast-lane ceiling → the next job must be deferred.
|
||
const { redis, queue, categoriesService } = makeDeps({
|
||
get: vi.fn(async (k: string) => (String(k).startsWith("prefetch:daily:pl24") ? "600" : null)),
|
||
});
|
||
const svc = new worker.PrefetchWorkerService(
|
||
queue as never,
|
||
queue as never,
|
||
categoriesService as never,
|
||
redis as never,
|
||
{ payload: vi.fn(async () => ({})) } as never,
|
||
{} as never,
|
||
);
|
||
const job = makeJob("prefetch-parts", {
|
||
vehicleId: "v1",
|
||
categoryId: "c1",
|
||
source: "pl24",
|
||
fast: true,
|
||
});
|
||
|
||
await expect(
|
||
(svc as never as { process: (j: unknown, t?: string) => Promise<void> }).process(job, "tok"),
|
||
).rejects.toThrow();
|
||
|
||
const [when] = job.moveToDelayed.mock.calls[0] as [number];
|
||
// Past the UTC rollover…
|
||
expect(when).toBeGreaterThan(Date.UTC(2026, 8, 21, 0, 0, 0));
|
||
// …and inside the window, not at 03:00 Istanbul like the old deferral.
|
||
const h = istanbulHour(when);
|
||
expect(h).toBeGreaterThanOrEqual(9);
|
||
expect(h).toBeLessThan(18);
|
||
});
|
||
});
|