Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ POLAR_PRO_YEARLY_PRODUCT_ID=YOUR_POLAR_YEARLY_PRODUCT_ID
# NOTE: MCP JWT は `BETTER_AUTH_SECRET` を署名鍵として共有する (audience で分離)。
# MCP JWTs are signed with `BETTER_AUTH_SECRET` (audience-scoped to `zedi-mcp`).

# Sentry DSNs (Epic #616). Each surface uses its own DSN so events are routed to
# the correct project. Leave unset locally — the SDKs no-op when DSN is empty.
# 各サーフェス(Web / 管理画面 / API)はそれぞれ別の DSN を使用する。
# 未設定でも SDK は no-op のため、ローカルでは空のままで良い。
# VITE_SENTRY_DSN_WEB=https://<public-key>@o0.ingest.sentry.io/<project>
# VITE_ADMIN_SENTRY_DSN=https://<public-key>@o0.ingest.sentry.io/<project>
# SENTRY_DSN_API=https://<public-key>@o0.ingest.sentry.io/<project>

# Docker Compose (docker-compose.dev.yml)
# Override defaults for local dev; required in shared/production. Do not commit real secrets.
# POSTGRES_USER=zedi
Expand Down
4 changes: 4 additions & 0 deletions admin/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@

# Main app URL for "Sign in" link on login page (default: https://zedi-note.app)
# VITE_MAIN_APP_URL=https://zedi-note.app

# Sentry DSN for the admin SPA (Epic #616). Leave unset locally — SDK no-ops when blank.
# 管理画面用 Sentry DSN。未設定なら SDK は no-op となる。
# VITE_ADMIN_SENTRY_DSN=https://<public-key>@o0.ingest.sentry.io/<project>
71 changes: 71 additions & 0 deletions admin/e2e/errors-page.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* 管理画面 `/errors` の最小 E2E。`AdminGuard` と一覧 API を `page.route` で
* モックし、ネットワーク到達不要で UI のレンダリングのみを検証する。
*
* Minimum E2E for the admin `/errors` page. Uses `page.route` to mock both the
* `AdminGuard` auth probe and the list API so the test does not depend on a
* running backend.
*
* @see https://github.com/otomatty/zedi/issues/804
*/
import { test, expect } from "@playwright/test";

const MOCK_ERROR = {
id: "00000000-0000-0000-0000-000000000001",
sentryIssueId: "sentry-1",
fingerprint: null,
title: "TypeError: cannot read properties of null",
route: "GET /api/users/:id",
statusCode: 500,
occurrences: 7,
firstSeenAt: "2026-05-01T00:00:00Z",
lastSeenAt: "2026-05-04T00:00:00Z",
severity: "high",
status: "open",
aiSummary: null,
aiSuspectedFiles: null,
aiRootCause: null,
aiSuggestedFix: null,
githubIssueNumber: null,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-04T00:00:00Z",
};

test.describe("Admin /errors page", () => {
test.beforeEach(async ({ page }) => {
// AdminGuard が呼ぶ `getAdminMe` を満たすモック。
// Mock the admin auth probe so AdminGuard renders its children.
await page.route("**/api/admin/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "admin-1", email: "admin@example.com", role: "admin" }),
});
});

// 一覧 API:ステータス指定の有無に関わらずモック行を返す。
// Errors list API: serve the same mock row regardless of filter params.
await page.route("**/api/admin/errors**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
errors: [MOCK_ERROR],
total: 1,
limit: 50,
offset: 0,
}),
});
});
});

test("renders the errors list with the mocked row", async ({ page }) => {
await page.goto("/errors");

// ページ見出しと、モック行のタイトル・ルートが描画されることを確認。
// Verify the page heading and the mocked row's title/route are rendered.
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await expect(page.getByText(MOCK_ERROR.title)).toBeVisible();
await expect(page.getByText(MOCK_ERROR.route)).toBeVisible();
});
});
4 changes: 3 additions & 1 deletion admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
"test:run": "vitest run",
"test:e2e": "playwright test --config playwright.config.ts"
},
"dependencies": {
"@sentry/react": "^10.51.0",
"@zedi/ui": "workspace:*",
"i18next": "^26.0.1",
"i18next-browser-languagedetector": "^8.2.1",
Expand Down
39 changes: 39 additions & 0 deletions admin/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* 管理画面 SPA 用の Playwright 設定。ルート (`playwright.config.ts`) はメインアプリ
* (ポート 5173)を起動するため、admin 用に別ポート (30001) を独立して立ち上げる。
*
* Playwright config dedicated to the admin SPA. The root config boots the main
* app on port 5173, so the admin needs its own server on port 30001 with
* separate test selection so the two suites don't collide.
*
* @see https://github.com/otomatty/zedi/issues/804
*/
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:30001",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "bun run dev -- --port 30001",
url: "http://localhost:30001",
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
stdout: "pipe",
stderr: "pipe",
},
});
2 changes: 2 additions & 0 deletions admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Users from "./pages/users";
import AuditLogs from "./pages/audit-logs";
import WikiHealth from "./pages/wiki-health";
import ActivityLog from "./pages/ActivityLog";
import Errors from "./pages/errors";

/**
* Root component for the admin SPA: sets up routing and the admin auth guard.
Expand All @@ -33,6 +34,7 @@ function App() {
<Route path="audit-logs" element={<AuditLogs />} />
<Route path="wiki-health" element={<WikiHealth />} />
<Route path="activity-log" element={<ActivityLog />} />
<Route path="errors" element={<Errors />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
Expand Down
109 changes: 109 additions & 0 deletions admin/src/api/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
patchAiModelsBulk,
previewSyncAiModels,
syncAiModels,
getApiErrors,
getApiErrorById,
patchApiErrorStatus,
type ApiErrorRow,
} from "./admin";

// `adminFetch` だけモックし、`getErrorMessage` は実装をそのまま使う
Expand Down Expand Up @@ -215,3 +219,108 @@ describe("syncAiModels", () => {
});
});
});

const sampleErrorRow: ApiErrorRow = {
id: "00000000-0000-0000-0000-000000000001",
sentryIssueId: "sentry-1",
fingerprint: null,
title: "TypeError",
route: "GET /api/x",
statusCode: 500,
occurrences: 1,
firstSeenAt: "2026-05-01T00:00:00Z",
lastSeenAt: "2026-05-04T00:00:00Z",
severity: "high",
status: "open",
aiSummary: null,
aiSuspectedFiles: null,
aiRootCause: null,
aiSuggestedFix: null,
githubIssueNumber: null,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-04T00:00:00Z",
};

describe("getApiErrors", () => {
beforeEach(() => {
vi.mocked(adminFetch).mockReset();
});

it("status / severity / limit / offset をクエリ文字列に渡す", async () => {
vi.mocked(adminFetch).mockResolvedValueOnce(
new Response(JSON.stringify({ errors: [sampleErrorRow], total: 1, limit: 10, offset: 0 }), {
status: 200,
}),
);
const out = await getApiErrors({ status: "open", severity: "high", limit: 10, offset: 0 });
expect(out.errors).toHaveLength(1);
expect(out.total).toBe(1);
expect(adminFetch).toHaveBeenCalledWith(
"/api/admin/errors?status=open&severity=high&limit=10&offset=0",
);
});

it("パラメータ無しのときはクエリ文字列を付けない", async () => {
vi.mocked(adminFetch).mockResolvedValueOnce(
new Response(JSON.stringify({ errors: [], total: 0, limit: 50, offset: 0 }), {
status: 200,
}),
);
const out = await getApiErrors();
expect(out.errors).toEqual([]);
expect(out.total).toBe(0);
expect(adminFetch).toHaveBeenCalledWith("/api/admin/errors");
});

it("!res.ok なら throw する", async () => {
vi.mocked(adminFetch).mockResolvedValueOnce(
new Response(JSON.stringify({ message: "boom" }), { status: 500 }),
);
await expect(getApiErrors()).rejects.toThrow(/boom/);
});
});

describe("getApiErrorById", () => {
beforeEach(() => {
vi.mocked(adminFetch).mockReset();
});

it("200 なら row を返し、id を URL エンコードする", async () => {
vi.mocked(adminFetch).mockResolvedValueOnce(
new Response(JSON.stringify({ error: sampleErrorRow }), { status: 200 }),
);
const out = await getApiErrorById(sampleErrorRow.id);
expect(out).toEqual(sampleErrorRow);
expect(adminFetch).toHaveBeenCalledWith(`/api/admin/errors/${sampleErrorRow.id}`);
});
});

describe("patchApiErrorStatus", () => {
beforeEach(() => {
vi.mocked(adminFetch).mockReset();
});

it("PATCH に status を載せ、更新後の row を返す", async () => {
const updated = { ...sampleErrorRow, status: "investigating" as const };
vi.mocked(adminFetch).mockResolvedValueOnce(
new Response(JSON.stringify({ error: updated }), { status: 200 }),
);
const out = await patchApiErrorStatus(sampleErrorRow.id, "investigating");
expect(out.status).toBe("investigating");
expect(adminFetch).toHaveBeenCalledWith(`/api/admin/errors/${sampleErrorRow.id}`, {
method: "PATCH",
body: JSON.stringify({ status: "investigating" }),
});
});

it("409 で throw する(並行更新競合)", async () => {
vi.mocked(adminFetch).mockResolvedValueOnce(
new Response(JSON.stringify({ message: "status changed concurrently; refetch and retry" }), {
status: 409,
}),
);
await expect(patchApiErrorStatus(sampleErrorRow.id, "resolved")).rejects.toThrow(
/status changed concurrently/,
);
});
});
Loading
Loading