refactor(db): migrate app pages and APIs from Prisma facade to Drizzle (5)

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-08-01 14:15:39 +02:00
1 parent 9aa4f331bf
commit 30b54e99e7
40 files changed
+1183 -784

No files matched your search

+15 -10
View File
@@ -1,14 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFindUnique = vi.hoisted(() => vi.fn());
const mockLimit = vi.hoisted(() => vi.fn());
const mockWhere = vi.hoisted(() => vi.fn(() => ({ limit: mockLimit })));
const mockFrom = vi.hoisted(() => vi.fn(() => ({ where: mockWhere })));
const mockSelect = vi.hoisted(() => vi.fn(() => ({ from: mockFrom })));
const mockRedisGet = vi.hoisted(() => vi.fn());
const mockRedisSetex = vi.hoisted(() => vi.fn());
const mockRedisDel = vi.hoisted(() => vi.fn());
vi.mock("@/lib/prisma", () => ({
prisma: {
user: { findUnique: mockFindUnique },
},
vi.mock("@/lib/db", () => ({
db: { select: mockSelect },
User: { id: "id", websiteJwtVersion: "websiteJwtVersion" },
}));
vi.mock("@/lib/redis", () => ({
@@ -23,29 +25,32 @@ describe("jwt-version-cache", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockWhere.mockReturnValue({ limit: mockLimit });
mockFrom.mockReturnValue({ where: mockWhere });
mockSelect.mockReturnValue({ from: mockFrom });
mockRedisGet.mockResolvedValue(null);
mockRedisSetex.mockResolvedValue("OK");
mockRedisDel.mockResolvedValue(1);
});
it("returns DB version and caches it", async () => {
mockFindUnique.mockResolvedValue({ websiteJwtVersion: 3 });
mockLimit.mockResolvedValue([{ websiteJwtVersion: 3 }]);
const { getCachedJwtVersion } = await import("./jwt-version-cache");
await expect(getCachedJwtVersion(42)).resolves.toBe(3);
expect(mockFindUnique).toHaveBeenCalledTimes(1);
expect(mockSelect).toHaveBeenCalledTimes(1);
await expect(getCachedJwtVersion(42)).resolves.toBe(3);
expect(mockFindUnique).toHaveBeenCalledTimes(1);
expect(mockSelect).toHaveBeenCalledTimes(1);
});
it("invalidates memory and redis entries", async () => {
mockFindUnique.mockResolvedValue({ websiteJwtVersion: 1 });
mockLimit.mockResolvedValue([{ websiteJwtVersion: 1 }]);
const { getCachedJwtVersion, invalidateJwtVersionCache } = await import(
"./jwt-version-cache"
);
await getCachedJwtVersion(7);
await invalidateJwtVersionCache(7);
expect(mockRedisDel).toHaveBeenCalled();
mockFindUnique.mockResolvedValue({ websiteJwtVersion: 2 });
mockLimit.mockResolvedValue([{ websiteJwtVersion: 2 }]);
await expect(getCachedJwtVersion(7)).resolves.toBe(2);
});
});
+7 -5
View File
@@ -1,6 +1,7 @@
import "server-only";
import { prisma } from "@/lib/prisma";
import { eq } from "drizzle-orm";
import { db, User } from "@/lib/db";
import { redis } from "@/lib/redis";
const MEMORY_TTL_MS = 60_000;
@@ -40,10 +41,11 @@ export async function getCachedJwtVersion(
}
try {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { websiteJwtVersion: true },
});
const [row] = await db
.select({ websiteJwtVersion: User.websiteJwtVersion })
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!row) return null;
const version = row.websiteJwtVersion;
memory.set(userId, { version, expiresAt: now + MEMORY_TTL_MS });
+25 -8
View File
@@ -1,9 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { generateSsoTicket, issueSsoTicket } from "./sso-ticket";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { generateSsoTicket } from "./sso-ticket";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const mockWhere = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockSet = vi.hoisted(() => vi.fn(() => ({ where: mockWhere })));
const mockUpdate = vi.hoisted(() => vi.fn(() => ({ set: mockSet })));
vi.mock("@/lib/db", () => ({
db: { update: mockUpdate },
User: { id: "id" },
}));
describe("generateSsoTicket", () => {
it("uses '{hotelName-without-spaces}-{uuidv4}'", () => {
const t = generateSsoTicket("Atom Hotel");
@@ -23,15 +32,23 @@ describe("generateSsoTicket", () => {
});
describe("issueSsoTicket", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSet.mockReturnValue({ where: mockWhere });
mockUpdate.mockReturnValue({ set: mockSet });
mockWhere.mockResolvedValue(undefined);
});
it("writes auth_ticket AND ip_current and returns the ticket", async () => {
const update = vi.fn().mockResolvedValue(undefined);
const db = { user: { update } };
const ticket = await issueSsoTicket(db, 42, "Atom Hotel", "1.2.3.4");
const { issueSsoTicket } = await import("./sso-ticket");
const ticket = await issueSsoTicket(42, "Atom Hotel", "1.2.3.4");
expect(ticket.startsWith("AtomHotel-")).toBe(true);
expect(update).toHaveBeenCalledWith({
where: { id: 42 },
data: { authTicket: ticket, ipCurrent: "1.2.3.4" },
expect(mockUpdate).toHaveBeenCalled();
expect(mockSet).toHaveBeenCalledWith({
authTicket: ticket,
ipCurrent: "1.2.3.4",
});
expect(mockWhere).toHaveBeenCalled();
});
});
+6 -15
View File
@@ -1,4 +1,6 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { db, User } from "@/lib/db";
/**
* Build the SSO ticket exactly like AtomCMS's User::ssoTicket():
@@ -12,30 +14,19 @@ export function generateSsoTicket(hotelName: string): string {
return `${normalized}-${randomUUID()}`;
}
/** Minimal shape of the Prisma client this needs (keeps it unit-testable). */
export interface SsoUserUpdater {
user: {
update(args: {
where: { id: number };
data: { authTicket: string; ipCurrent: string };
}): Promise<unknown>;
};
}
/**
* Generate a ticket and persist it like AtomCMS: writes auth_ticket AND
* ip_current on the user, then returns the ticket for the client launcher.
*/
export async function issueSsoTicket(
db: SsoUserUpdater,
userId: number,
hotelName: string,
ip: string,
): Promise<string> {
const ticket = generateSsoTicket(hotelName);
await db.user.update({
where: { id: userId },
data: { authTicket: ticket, ipCurrent: ip },
});
await db
.update(User)
.set({ authTicket: ticket, ipCurrent: ip })
.where(eq(User.id, userId));
return ticket;
}