feat(dm-dashboard): Wave 2 — Drizzle migration, DmSidebar fix, CORS config

This commit is contained in:
emil
2026-05-15 21:48:09 +03:00
parent 25f43ec4e2
commit 0bbaa6e3d8
36 changed files with 1294 additions and 18 deletions
+117
View File
@@ -0,0 +1,117 @@
import { describe, it, expect } from "vitest";
import {
getCorsHeaders,
createCorsResponse,
handleCorsPreflight,
jsonResponse,
} from "../src/lib/cors";
describe("CORS utility", () => {
describe("getCorsHeaders", () => {
it("returns headers for allowed origin randify.pro", () => {
const headers = getCorsHeaders("https://randify.pro");
expect(headers["Access-Control-Allow-Origin"]).toBe("https://randify.pro");
expect(headers["Access-Control-Allow-Methods"]).toBe("GET, POST, OPTIONS");
expect(headers["Access-Control-Allow-Headers"]).toBe("Content-Type, Authorization");
expect(headers["Access-Control-Allow-Credentials"]).toBe("true");
expect(headers["Vary"]).toBe("Origin");
});
it("returns headers for allowed origin dm.randify.pro", () => {
const headers = getCorsHeaders("https://dm.randify.pro");
expect(headers["Access-Control-Allow-Origin"]).toBe("https://dm.randify.pro");
});
it("returns headers for allowed origin localhost:4321", () => {
const headers = getCorsHeaders("http://localhost:4321");
expect(headers["Access-Control-Allow-Origin"]).toBe("http://localhost:4321");
});
it("returns empty origin for disallowed host", () => {
const headers = getCorsHeaders("https://evil.com");
expect(headers["Access-Control-Allow-Origin"]).toBe("");
});
it("returns empty origin for null origin", () => {
const headers = getCorsHeaders(null);
expect(headers["Access-Control-Allow-Origin"]).toBe("");
});
it("returns empty origin for invalid URL", () => {
const headers = getCorsHeaders("not-a-url");
expect(headers["Access-Control-Allow-Origin"]).toBe("");
});
});
describe("handleCorsPreflight", () => {
it("returns 204 with CORS headers for allowed origin", () => {
const response = handleCorsPreflight("https://randify.pro");
expect(response.status).toBe(204);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://randify.pro");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Credentials")).toBe("true");
});
it("returns 204 even for disallowed origin", () => {
const response = handleCorsPreflight("https://evil.com");
expect(response.status).toBe(204);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("");
});
});
describe("createCorsResponse", () => {
it("wraps a response with CORS headers", () => {
const response = createCorsResponse("hello", 200, "https://dm.randify.pro", {
"Content-Type": "text/plain",
});
expect(response.status).toBe(200);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://dm.randify.pro");
expect(response.headers.get("Content-Type")).toBe("text/plain");
});
});
describe("jsonResponse", () => {
it("serializes data and sets JSON content type", async () => {
const response = jsonResponse({ ok: true }, 200, "http://localhost:4321");
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/json");
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:4321");
const body = await response.json();
expect(body).toEqual({ ok: true });
});
});
});
function mockRequest(url: string, origin: string, method = "GET") {
return {
url,
method,
headers: {
get(name: string) {
if (name.toLowerCase() === "origin") return origin;
return null;
},
},
} as unknown as Request;
}
describe("DM API health route", () => {
it("GET returns 200 with CORS headers", async () => {
const { GET } = await import("../src/pages/api/dm/health");
const request = mockRequest("https://dm.randify.pro/api/dm/health", "https://randify.pro");
const response = await GET!({ request, url: new URL(request.url), ...({} as any) });
expect(response.status).toBe(200);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://randify.pro");
const body = await response.json();
expect(body.status).toBe("ok");
expect(body.dm).toBe(true);
});
it("OPTIONS returns 204 with CORS headers", async () => {
const { OPTIONS } = await import("../src/pages/api/dm/health");
const request = mockRequest("https://dm.randify.pro/api/dm/health", "https://dm.randify.pro", "OPTIONS");
const response = await OPTIONS!({ request, url: new URL(request.url), ...({} as any) });
expect(response.status).toBe(204);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://dm.randify.pro");
});
});
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from "@playwright/test";
test.describe("DM Sidebar", () => {
test("renders navigation sections when logged out", async ({ page }, testInfo) => {
if (testInfo.project.name === "mobile-chromium") test.skip();
await page.goto("/dm/");
const sidebar = page.locator("aside.w-full");
await expect(sidebar).toBeVisible();
await expect(sidebar.getByText("ИНСТРУМЕНТЫ", { exact: true })).toBeVisible();
await expect(sidebar.getByRole("link", { name: "Кубики" })).toBeVisible();
await expect(sidebar.getByRole("link", { name: "Инициатива" })).toBeVisible();
await expect(sidebar.getByRole("link", { name: "Справочник" })).toBeVisible();
await expect(sidebar.getByRole("link", { name: "Заметки" })).toBeVisible();
});
test("does not show user block when logged out", async ({ page }) => {
await page.goto("/dm/");
await expect(page.locator("[data-testid='sidebar-user-block']")).toHaveCount(0);
});
test("user block and tier badge have correct DOM structure", async ({ page }, testInfo) => {
if (testInfo.project.name === "mobile-chromium") test.skip();
await page.goto("/dm/");
await page.evaluate(() => {
const aside = document.querySelector("aside.w-full");
if (!aside) return;
const block = document.createElement("div");
block.setAttribute("data-testid", "sidebar-user-block");
block.className = "flex items-center gap-3 mb-6 px-3 py-3 rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)]";
block.innerHTML = `
<div class="w-8 h-8 rounded-full bg-[var(--accent)]/20 flex items-center justify-center text-[var(--accent)] text-sm font-bold">T</div>
<div class="flex items-center gap-2 min-w-0">
<span class="text-sm font-medium text-[var(--text-primary)] truncate">Test User</span>
<span data-testid="tier-badge" data-tier="pro" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide bg-[var(--accent)] text-white">PRO</span>
</div>
`;
aside.prepend(block);
});
const userBlock = page.locator("[data-testid='sidebar-user-block']");
await expect(userBlock).toBeVisible();
await expect(page.getByText("Test User")).toBeVisible();
const badge = page.locator("[data-testid='tier-badge']");
await expect(badge).toBeVisible();
await expect(badge).toHaveText("PRO");
await expect(badge).toHaveAttribute("data-tier", "pro");
});
});