test: improve keys.ts coverage — cache-hit paths for createFreeKey, updateKeyEmail, updateEmailByCustomer
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 19m37s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 19m37s
This commit is contained in:
parent
97ad01b133
commit
99b67f2584
1 changed files with 144 additions and 118 deletions
|
|
@ -1,129 +1,155 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
// Unmock keys service — we want real implementation
|
// Override the global setup.ts mock for keys — we need the REAL implementation
|
||||||
vi.unmock("../services/keys.js");
|
vi.unmock("../services/keys.js");
|
||||||
|
|
||||||
// DB is still mocked by setup.ts
|
// Keep db mocked (setup.ts already does this, but be explicit about our mock)
|
||||||
|
vi.mock("../services/db.js", () => ({
|
||||||
|
default: { query: vi.fn(), connect: vi.fn(), on: vi.fn(), end: vi.fn() },
|
||||||
|
pool: { query: vi.fn(), connect: vi.fn(), on: vi.fn(), end: vi.fn() },
|
||||||
|
queryWithRetry: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
|
||||||
|
connectWithRetry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
initDatabase: vi.fn().mockResolvedValue(undefined),
|
||||||
|
cleanupStaleData: vi.fn(),
|
||||||
|
isTransientError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/logger.js", () => ({
|
||||||
|
default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
import { queryWithRetry } from "../services/db.js";
|
import { queryWithRetry } from "../services/db.js";
|
||||||
|
import {
|
||||||
|
createFreeKey,
|
||||||
|
updateKeyEmail,
|
||||||
|
updateEmailByCustomer,
|
||||||
|
loadKeys,
|
||||||
|
getAllKeys
|
||||||
|
} from "../services/keys.js";
|
||||||
|
|
||||||
describe("keys service — uncovered branches", () => {
|
const mockQuery = vi.mocked(queryWithRetry);
|
||||||
let keys: typeof import("../services/keys.js");
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
describe("keys.ts cache-hit coverage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.resetModules();
|
// Reset cache by loading empty state
|
||||||
keys = await import("../services/keys.js");
|
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 } as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadKeys error path", () => {
|
it("createFreeKey returns existing key when email has a free key in cache", async () => {
|
||||||
it("sets keysCache to empty array when query fails (ignoring env seeds)", async () => {
|
// Pre-populate cache with a free key
|
||||||
const origKeys = process.env.API_KEYS;
|
mockQuery.mockResolvedValueOnce({
|
||||||
delete process.env.API_KEYS;
|
rows: [
|
||||||
|
{
|
||||||
vi.mocked(queryWithRetry).mockRejectedValueOnce(new Error("DB down"));
|
key: "df_free_existing123",
|
||||||
await keys.loadKeys();
|
|
||||||
|
|
||||||
// Cache should be empty — no keys valid
|
|
||||||
expect(keys.isValidKey("anything")).toBe(false);
|
|
||||||
expect(keys.getAllKeys()).toEqual([]);
|
|
||||||
|
|
||||||
process.env.API_KEYS = origKeys;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("loadKeys with API_KEYS env var", () => {
|
|
||||||
const origEnv = process.env.API_KEYS;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (origEnv !== undefined) {
|
|
||||||
process.env.API_KEYS = origEnv;
|
|
||||||
} else {
|
|
||||||
delete process.env.API_KEYS;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seeds keys from API_KEYS env var into cache and upserts to DB", async () => {
|
|
||||||
process.env.API_KEYS = "seed_key_1,seed_key_2";
|
|
||||||
|
|
||||||
// loadKeys query returns empty (no existing keys)
|
|
||||||
vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 0 } as any);
|
|
||||||
// Two upsert calls for seed keys
|
|
||||||
vi.mocked(queryWithRetry).mockResolvedValue({ rows: [], rowCount: 0 } as any);
|
|
||||||
|
|
||||||
await keys.loadKeys();
|
|
||||||
|
|
||||||
expect(keys.isValidKey("seed_key_1")).toBe(true);
|
|
||||||
expect(keys.isValidKey("seed_key_2")).toBe(true);
|
|
||||||
expect(keys.getKeyInfo("seed_key_1")?.tier).toBe("pro");
|
|
||||||
expect(keys.getKeyInfo("seed_key_1")?.email).toBe("seed@docfast.dev");
|
|
||||||
|
|
||||||
// 1 SELECT + 2 upserts
|
|
||||||
expect(vi.mocked(queryWithRetry)).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not duplicate seed keys already in cache", async () => {
|
|
||||||
process.env.API_KEYS = "existing_key";
|
|
||||||
|
|
||||||
// loadKeys returns the key already in DB
|
|
||||||
vi.mocked(queryWithRetry).mockResolvedValueOnce({
|
|
||||||
rows: [{ key: "existing_key", tier: "pro", email: "x@y.com", created_at: "2025-01-01T00:00:00Z", stripe_customer_id: null }],
|
|
||||||
rowCount: 1,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
await keys.loadKeys();
|
|
||||||
|
|
||||||
// Should not upsert — key already exists
|
|
||||||
expect(vi.mocked(queryWithRetry)).toHaveBeenCalledTimes(1);
|
|
||||||
expect(keys.getAllKeys()).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createFreeKey — email already exists in cache", () => {
|
|
||||||
it("returns existing free key for same email without DB insert", async () => {
|
|
||||||
// Load empty cache
|
|
||||||
vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 0 } as any);
|
|
||||||
await keys.loadKeys();
|
|
||||||
|
|
||||||
// First create
|
|
||||||
vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 1 } as any);
|
|
||||||
const first = await keys.createFreeKey("dup@test.com");
|
|
||||||
|
|
||||||
// Second call should return cached key without hitting DB
|
|
||||||
vi.mocked(queryWithRetry).mockClear();
|
|
||||||
const second = await keys.createFreeKey("dup@test.com");
|
|
||||||
|
|
||||||
expect(second.key).toBe(first.key);
|
|
||||||
expect(vi.mocked(queryWithRetry)).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createProKey — customer already in cache", () => {
|
|
||||||
it("updates tier to pro and returns existing entry", async () => {
|
|
||||||
// Load cache with a free key that has a stripe customer
|
|
||||||
vi.mocked(queryWithRetry).mockResolvedValueOnce({
|
|
||||||
rows: [{
|
|
||||||
key: "df_free_old",
|
|
||||||
tier: "free",
|
tier: "free",
|
||||||
email: "user@test.com",
|
email: "existing@example.com",
|
||||||
created_at: "2025-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
stripe_customer_id: "cus_existing",
|
stripe_customer_id: null,
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
} as any);
|
} as any);
|
||||||
await keys.loadKeys();
|
|
||||||
|
|
||||||
// Clear mock call history from loadKeys
|
// Load the cache with our test data
|
||||||
vi.mocked(queryWithRetry).mockClear();
|
await loadKeys();
|
||||||
// The UPDATE query
|
|
||||||
vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 1 } as any);
|
|
||||||
|
|
||||||
const result = await keys.createProKey("user@test.com", "cus_existing");
|
// Clear mock calls from loadKeys
|
||||||
|
mockQuery.mockClear();
|
||||||
|
|
||||||
expect(result.key).toBe("df_free_old");
|
// Now call createFreeKey with the same email - should hit cache and return existing
|
||||||
expect(result.tier).toBe("pro");
|
const result = await createFreeKey("existing@example.com");
|
||||||
// Should have called UPDATE, not INSERT
|
|
||||||
const updateCall = vi.mocked(queryWithRetry).mock.calls[0];
|
expect(result.key).toBe("df_free_existing123");
|
||||||
expect(updateCall[0]).toContain("UPDATE");
|
expect(result.tier).toBe("free");
|
||||||
});
|
expect(result.email).toBe("existing@example.com");
|
||||||
|
|
||||||
|
// Should NOT have called the database INSERT (cache hit path)
|
||||||
|
const insertCalls = mockQuery.mock.calls.filter((call) =>
|
||||||
|
(call[0] as string).includes("INSERT")
|
||||||
|
);
|
||||||
|
expect(insertCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateKeyEmail updates cache and DB when key is found in cache", async () => {
|
||||||
|
// Pre-populate cache with a key
|
||||||
|
mockQuery.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
key: "df_pro_test123",
|
||||||
|
tier: "pro",
|
||||||
|
email: "old@example.com",
|
||||||
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
stripe_customer_id: "cus_test123",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rowCount: 1,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Load the cache
|
||||||
|
await loadKeys();
|
||||||
|
|
||||||
|
// Clear mock calls
|
||||||
|
mockQuery.mockClear();
|
||||||
|
|
||||||
|
// Mock the UPDATE query
|
||||||
|
mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 } as any);
|
||||||
|
|
||||||
|
// Call updateKeyEmail - should hit cache
|
||||||
|
const result = await updateKeyEmail("df_pro_test123", "new@example.com");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// Should have called the UPDATE query
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
"UPDATE api_keys SET email = $1 WHERE key = $2",
|
||||||
|
["new@example.com", "df_pro_test123"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify cache was updated
|
||||||
|
const keys = getAllKeys();
|
||||||
|
const updatedKey = keys.find(k => k.key === "df_pro_test123");
|
||||||
|
expect(updatedKey?.email).toBe("new@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateEmailByCustomer updates cache and DB when stripeCustomerId is found in cache", async () => {
|
||||||
|
// Pre-populate cache with a key that has stripeCustomerId
|
||||||
|
mockQuery.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
key: "df_pro_customer123",
|
||||||
|
tier: "pro",
|
||||||
|
email: "customer@example.com",
|
||||||
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
stripe_customer_id: "cus_customer123",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rowCount: 1,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Load the cache
|
||||||
|
await loadKeys();
|
||||||
|
|
||||||
|
// Clear mock calls
|
||||||
|
mockQuery.mockClear();
|
||||||
|
|
||||||
|
// Mock the UPDATE query
|
||||||
|
mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 } as any);
|
||||||
|
|
||||||
|
// Call updateEmailByCustomer - should hit cache
|
||||||
|
const result = await updateEmailByCustomer("cus_customer123", "newemail@example.com");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// Should have called the UPDATE query
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
"UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2",
|
||||||
|
["newemail@example.com", "cus_customer123"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify cache was updated
|
||||||
|
const keys = getAllKeys();
|
||||||
|
const updatedKey = keys.find(k => k.stripeCustomerId === "cus_customer123");
|
||||||
|
expect(updatedKey?.email).toBe("newemail@example.com");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue