refactor: extract findKeyInCacheOrDb to DRY up DB fallback pattern (TDD)
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 20m17s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 20m17s
- New shared helper findKeyInCacheOrDb(column, value) for DB lookups - Refactored downgradeByCustomer, updateKeyEmail, updateEmailByCustomer, and findKeyByCustomerId to use the shared helper - Eliminated ~60 lines of duplicated SELECT/row-mapping code - 3 TDD tests added (keys-db-fallback-helper.test.ts) - 636 tests passing, 0 tsc errors
This commit is contained in:
parent
4e00feb860
commit
25cb5e2e94
2 changed files with 103 additions and 64 deletions
68
src/__tests__/keys-db-fallback-helper.test.ts
Normal file
68
src/__tests__/keys-db-fallback-helper.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.unmock("../services/keys.js");
|
||||||
|
|
||||||
|
// The DB mock is set up in setup.ts — we need to control queryWithRetry
|
||||||
|
const mockQueryWithRetry = vi.fn();
|
||||||
|
vi.mock("../services/db.js", () => ({
|
||||||
|
default: { query: vi.fn(), end: vi.fn() },
|
||||||
|
pool: { query: vi.fn(), end: vi.fn() },
|
||||||
|
queryWithRetry: (...args: unknown[]) => mockQueryWithRetry(...args),
|
||||||
|
connectWithRetry: vi.fn(),
|
||||||
|
initDatabase: vi.fn(),
|
||||||
|
cleanupStaleData: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { findKeyInCacheOrDb } from "../services/keys.js";
|
||||||
|
|
||||||
|
describe("findKeyInCacheOrDb", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockQueryWithRetry.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when DB finds no row", async () => {
|
||||||
|
mockQueryWithRetry.mockResolvedValue({ rows: [] });
|
||||||
|
const result = await findKeyInCacheOrDb("stripe_customer_id", "cus_nonexistent");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockQueryWithRetry).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("WHERE stripe_customer_id = $1"),
|
||||||
|
["cus_nonexistent"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ApiKey when DB finds a row", async () => {
|
||||||
|
mockQueryWithRetry.mockResolvedValue({
|
||||||
|
rows: [{
|
||||||
|
key: "df_pro_abc",
|
||||||
|
tier: "pro",
|
||||||
|
email: "test@example.com",
|
||||||
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
stripe_customer_id: "cus_123",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const result = await findKeyInCacheOrDb("stripe_customer_id", "cus_123");
|
||||||
|
expect(result).toEqual({
|
||||||
|
key: "df_pro_abc",
|
||||||
|
tier: "pro",
|
||||||
|
email: "test@example.com",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
stripeCustomerId: "cus_123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Date objects in created_at", async () => {
|
||||||
|
mockQueryWithRetry.mockResolvedValue({
|
||||||
|
rows: [{
|
||||||
|
key: "df_pro_abc",
|
||||||
|
tier: "pro",
|
||||||
|
email: "test@example.com",
|
||||||
|
created_at: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
stripe_customer_id: null,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const result = await findKeyInCacheOrDb("key", "df_pro_abc");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.createdAt).toBe("2026-01-01T00:00:00.000Z");
|
||||||
|
expect(result!.stripeCustomerId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,6 +14,26 @@ export interface ApiKey {
|
||||||
// In-memory cache for fast lookups, synced with PostgreSQL
|
// In-memory cache for fast lookups, synced with PostgreSQL
|
||||||
let keysCache: ApiKey[] = [];
|
let keysCache: ApiKey[] = [];
|
||||||
|
|
||||||
|
/** Look up a key row in the DB by a given column. Returns null if not found. */
|
||||||
|
export async function findKeyInCacheOrDb(
|
||||||
|
column: "key" | "stripe_customer_id" | "email",
|
||||||
|
value: string
|
||||||
|
): Promise<ApiKey | null> {
|
||||||
|
const result = await queryWithRetry(
|
||||||
|
`SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE ${column} = $1 LIMIT 1`,
|
||||||
|
[value]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
const r = result.rows[0];
|
||||||
|
return {
|
||||||
|
key: r.key,
|
||||||
|
tier: r.tier as "free" | "pro",
|
||||||
|
email: r.email,
|
||||||
|
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||||
|
stripeCustomerId: r.stripe_customer_id || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadKeys(): Promise<void> {
|
export async function loadKeys(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const result = await queryWithRetry(
|
const result = await queryWithRetry(
|
||||||
|
|
@ -137,47 +157,22 @@ export async function downgradeByCustomer(stripeCustomerId: string): Promise<boo
|
||||||
|
|
||||||
// DB fallback: key may exist on another pod's cache or after a restart
|
// DB fallback: key may exist on another pod's cache or after a restart
|
||||||
logger.info({ stripeCustomerId }, "downgradeByCustomer: cache miss, falling back to DB");
|
logger.info({ stripeCustomerId }, "downgradeByCustomer: cache miss, falling back to DB");
|
||||||
const result = await queryWithRetry(
|
const dbKey = await findKeyInCacheOrDb("stripe_customer_id", stripeCustomerId);
|
||||||
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE stripe_customer_id = $1 LIMIT 1",
|
if (!dbKey) {
|
||||||
[stripeCustomerId]
|
|
||||||
);
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
logger.warn({ stripeCustomerId }, "downgradeByCustomer: customer not found in cache or DB");
|
logger.warn({ stripeCustomerId }, "downgradeByCustomer: customer not found in cache or DB");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = result.rows[0];
|
|
||||||
await queryWithRetry("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]);
|
await queryWithRetry("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]);
|
||||||
|
dbKey.tier = "free";
|
||||||
|
keysCache.push(dbKey);
|
||||||
|
|
||||||
// Add to local cache so subsequent lookups on this pod work
|
logger.info({ stripeCustomerId, key: dbKey.key }, "downgradeByCustomer: downgraded via DB fallback");
|
||||||
const cached: ApiKey = {
|
|
||||||
key: row.key,
|
|
||||||
tier: "free",
|
|
||||||
email: row.email,
|
|
||||||
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
|
|
||||||
stripeCustomerId: row.stripe_customer_id || undefined,
|
|
||||||
};
|
|
||||||
keysCache.push(cached);
|
|
||||||
|
|
||||||
logger.info({ stripeCustomerId, key: row.key }, "downgradeByCustomer: downgraded via DB fallback");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findKeyByCustomerId(stripeCustomerId: string): Promise<ApiKey | null> {
|
export async function findKeyByCustomerId(stripeCustomerId: string): Promise<ApiKey | null> {
|
||||||
// Check DB directly — survives pod restarts unlike in-memory cache
|
return findKeyInCacheOrDb("stripe_customer_id", stripeCustomerId);
|
||||||
const result = await queryWithRetry(
|
|
||||||
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE stripe_customer_id = $1 LIMIT 1",
|
|
||||||
[stripeCustomerId]
|
|
||||||
);
|
|
||||||
if (result.rows.length === 0) return null;
|
|
||||||
const r = result.rows[0];
|
|
||||||
return {
|
|
||||||
key: r.key,
|
|
||||||
tier: r.tier as "free" | "pro",
|
|
||||||
email: r.email,
|
|
||||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
|
||||||
stripeCustomerId: r.stripe_customer_id || undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllKeys(): ApiKey[] {
|
export function getAllKeys(): ApiKey[] {
|
||||||
|
|
@ -194,27 +189,15 @@ export async function updateKeyEmail(apiKey: string, newEmail: string): Promise<
|
||||||
|
|
||||||
// DB fallback: key may exist on another pod's cache or after a restart
|
// DB fallback: key may exist on another pod's cache or after a restart
|
||||||
logger.info({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: cache miss, falling back to DB");
|
logger.info({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: cache miss, falling back to DB");
|
||||||
const result = await queryWithRetry(
|
const dbKey = await findKeyInCacheOrDb("key", apiKey);
|
||||||
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE key = $1 LIMIT 1",
|
if (!dbKey) {
|
||||||
[apiKey]
|
|
||||||
);
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
logger.warn({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: key not found in cache or DB");
|
logger.warn({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: key not found in cache or DB");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = result.rows[0];
|
|
||||||
await queryWithRetry("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]);
|
await queryWithRetry("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]);
|
||||||
|
dbKey.email = newEmail;
|
||||||
// Hydrate local cache
|
keysCache.push(dbKey);
|
||||||
const cached: ApiKey = {
|
|
||||||
key: row.key,
|
|
||||||
tier: row.tier as "free" | "pro",
|
|
||||||
email: newEmail,
|
|
||||||
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
|
|
||||||
stripeCustomerId: row.stripe_customer_id || undefined,
|
|
||||||
};
|
|
||||||
keysCache.push(cached);
|
|
||||||
|
|
||||||
logger.info({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: updated via DB fallback");
|
logger.info({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: updated via DB fallback");
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -230,28 +213,16 @@ export async function updateEmailByCustomer(stripeCustomerId: string, newEmail:
|
||||||
|
|
||||||
// DB fallback: key may exist on another pod's cache or after a restart
|
// DB fallback: key may exist on another pod's cache or after a restart
|
||||||
logger.info({ stripeCustomerId }, "updateEmailByCustomer: cache miss, falling back to DB");
|
logger.info({ stripeCustomerId }, "updateEmailByCustomer: cache miss, falling back to DB");
|
||||||
const result = await queryWithRetry(
|
const dbKey = await findKeyInCacheOrDb("stripe_customer_id", stripeCustomerId);
|
||||||
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE stripe_customer_id = $1 LIMIT 1",
|
if (!dbKey) {
|
||||||
[stripeCustomerId]
|
|
||||||
);
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
logger.warn({ stripeCustomerId }, "updateEmailByCustomer: customer not found in cache or DB");
|
logger.warn({ stripeCustomerId }, "updateEmailByCustomer: customer not found in cache or DB");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = result.rows[0];
|
|
||||||
await queryWithRetry("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]);
|
await queryWithRetry("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]);
|
||||||
|
dbKey.email = newEmail;
|
||||||
|
keysCache.push(dbKey);
|
||||||
|
|
||||||
// Hydrate local cache
|
logger.info({ stripeCustomerId, key: dbKey.key }, "updateEmailByCustomer: updated via DB fallback");
|
||||||
const cached: ApiKey = {
|
|
||||||
key: row.key,
|
|
||||||
tier: row.tier as "free" | "pro",
|
|
||||||
email: newEmail,
|
|
||||||
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
|
|
||||||
stripeCustomerId: row.stripe_customer_id || undefined,
|
|
||||||
};
|
|
||||||
keysCache.push(cached);
|
|
||||||
|
|
||||||
logger.info({ stripeCustomerId, key: row.key }, "updateEmailByCustomer: updated via DB fallback");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue