Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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