/** * U3 — Calendar data consistency E2E. * * Regression coverage for issue #2 (2026-06-28): calendar events created * via the API (or agent tool) were not visible in the UI calendar panel. * Root cause was a user_id mismatch — the CalendarTool's default_user_id * resolved to a stale admin row while the UI queried events for the * logged-in JWT user_id. * * Strategy: * - Create events via the REST API (POST /calendar/events) as the test * user (JWT-scoped) — this mirrors the "user creates event" path. * - Verify the events appear in the UI calendar panel. * - Verify user_id integrity: the event's user_id must match the logged- * in user, not a hallucinated "default" / "zhangsan" value. * - Verify delete syncs between API and UI. * * ponytail: the agent-created path (ReAct → CalendarTool) uses * default_user_id which is resolved once at server startup from the * first admin row in auth.db. In dev/test mode with a stale admin user, * this may NOT match the logged-in JWT user_id — a known single-user * simplification (see app.py:440 comment). We do NOT test the agent * path here; that's covered by the calendar.spec.ts agent-flow tests. */ import { test, expect, type Page } from '@playwright/test' import { TEST_USER, clearAuth } from './helpers' // ── API helpers ──────────────────────────────────────────────────────── const API_BASE = 'http://127.0.0.1:8000/api/v1' const CALENDAR_BASE = `${API_BASE}/calendar` const AUTH_BASE = `${API_BASE}/auth` let _cachedToken: string | null = null let _cachedUserId: string | null = null async function getAccessToken(): Promise { if (_cachedToken) return _cachedToken const resp = await fetch(`${AUTH_BASE}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: TEST_USER.username, password: TEST_USER.password, }), }) if (!resp.ok) throw new Error(`Login failed: ${resp.status}`) const data = (await resp.json()) as { access_token: string } _cachedToken = data.access_token return _cachedToken } /** Get the test user's user_id by decoding the JWT's `sub` claim. */ async function getUserId(): Promise { if (_cachedUserId) return _cachedUserId const token = await getAccessToken() // JWT format: header.payload.signature — decode payload (base64url). const payload = token.split('.')[1] const decoded = JSON.parse( Buffer.from(payload, 'base64url').toString('utf-8'), ) as { sub: string } _cachedUserId = decoded.sub return _cachedUserId } interface CalendarEvent { id: string title: string user_id: string start_time: string end_time: string } /** Create an event via the REST API as the test user. */ async function createEventViaApi(opts: { title: string startIso: string endIso: string description?: string }): Promise { const token = await getAccessToken() const resp = await fetch(`${CALENDAR_BASE}/events`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ title: opts.title, start_time: opts.startIso, end_time: opts.endIso, description: opts.description ?? '', }), }) if (!resp.ok) { throw new Error(`createEventViaApi failed: ${resp.status} ${await resp.text()}`) } const body = (await resp.json()) as { event: CalendarEvent } return body.event } /** List events via the REST API as the test user. */ async function listEventsViaApi(): Promise { const token = await getAccessToken() const resp = await fetch(`${CALENDAR_BASE}/events`, { headers: { Authorization: `Bearer ${token}` }, }) if (!resp.ok) throw new Error(`listEventsViaApi failed: ${resp.status}`) const body = (await resp.json()) as { events: CalendarEvent[] } return body.events } /** Delete an event via the REST API. */ async function deleteEventViaApi(id: string): Promise { const token = await getAccessToken() const resp = await fetch(`${CALENDAR_BASE}/events/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }) if (!resp.ok) throw new Error(`deleteEventViaApi failed: ${resp.status}`) } /** Delete all events whose title starts with the e2e prefix. */ async function cleanupE2EEvents(): Promise { const events = await listEventsViaApi() await Promise.all( events .filter((e) => e.title.startsWith('E2E_')) .map((e) => deleteEventViaApi(e.id).catch(() => {})), ) } // ── Time helpers ─────────────────────────────────────────────────────── /** * Return an ISO 8601 timestamp `hoursFromNow` hours in the future. * The backend stores events in UTC and the calendar panel renders * "today" / "upcoming" relative to the server's local time. Using * future timestamps ensures the events show up in "今日" or "即将到来". */ function isoHoursFromNow(hoursFromNow: number): string { const d = new Date(Date.now() + hoursFromNow * 3600_000) // toISOString() returns UTC with 'Z' suffix — the backend accepts this. return d.toISOString() } // ── UI helpers ───────────────────────────────────────────────────────── /** * Login and navigate to a workbench view (not /agent/chat which uses the * chat-only layout). Then click the "日历" tab in the bottom-right * quadrant to mount the CalendarPanel. * * Follows the proven pattern from calendar.spec.ts: SPA-navigate via the * TopNav "setting" button (avoids cold-start whoami timing issues that * page.goto('/agent/monitor') can trigger). */ async function loginAndOpenCalendar(page: Page): Promise { await page.goto('/login') await clearAuth(page) await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username) await page.getByPlaceholder('请输入密码').fill(TEST_USER.password) await page.getByRole('button', { name: /登\s*录/ }).click() // Wait for redirect to /agent await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 }) // Click the settings icon in TopNav to SPA-navigate to /agent/monitor // (workbench layout). The button's accessible name is "setting". await page.getByRole('button', { name: 'setting' }).click() // Wait for the QuadrantPanel tabs to render (workbench layout). await expect(page.locator('.quadrant-panel__tab').first()).toBeVisible({ timeout: 15_000, }) // Click the "日历" tab in the bottom-right panel. const calendarTab = page.locator('.quadrant-panel__tab', { hasText: '日历' }) await calendarTab.click() // The CalendarPanel should mount and show its header. await expect(page.locator('.calendar-panel')).toBeVisible({ timeout: 10_000 }) // Wait for loading to finish (the "加载日程..." spinner disappears). await expect(page.locator('.calendar-panel__loading')).toBeHidden({ timeout: 15_000, }) } // ── Tests ────────────────────────────────────────────────────────────── test.describe('Calendar data consistency', () => { test.beforeEach(async () => { await cleanupE2EEvents() }) test.afterEach(async () => { await cleanupE2EEvents() }) test('event created via API is visible in the UI calendar panel', async ({ page }) => { const title = `E2E_API_VISIBLE_${Date.now().toString(36)}` await createEventViaApi({ title, startIso: isoHoursFromNow(2), endIso: isoHoursFromNow(3), }) await loginAndOpenCalendar(page) // The event should appear in the calendar panel — either in "今日" // or "即将到来" depending on the exact time. Poll because the panel // fetches events on mount. await expect .poll( async () => { const items = page.locator('.calendar-panel__item') const count = await items.count() for (let i = 0; i < count; i++) { const text = await items.nth(i).textContent() if (text && text.includes(title)) return true } return false }, { timeout: 15_000, intervals: [500] }, ) .toBe(true) }) test('event user_id matches the logged-in user, not a hallucinated value', async () => { const title = `E2E_UID_${Date.now().toString(36)}` const created = await createEventViaApi({ title, startIso: isoHoursFromNow(4), endIso: isoHoursFromNow(5), }) const expectedUserId = await getUserId() // Direct field check on the creation response. expect(created.user_id).toBe(expectedUserId) // Re-query via list to verify persistence. const events = await listEventsViaApi() const found = events.find((e) => e.id === created.id) expect(found).toBeDefined() expect(found?.user_id).toBe(expectedUserId) // Guard against the regression: user_id must never be a hallucinated // placeholder value. These are the values the agent tool used to // produce when default_user_id resolution failed. const hallucinated = ['default', 'zhangsan', 'admin', 'test', ''] expect(hallucinated).not.toContain(found?.user_id) }) test('multiple events all render in the calendar panel', async ({ page }) => { const titles = [ `E2E_MULTI_1_${Date.now().toString(36)}`, `E2E_MULTI_2_${Date.now().toString(36)}`, `E2E_MULTI_3_${Date.now().toString(36)}`, ] // Stagger: +6h, +12h, +24h so they land in "今日" / "即将到来". for (let i = 0; i < titles.length; i++) { await createEventViaApi({ title: titles[i], startIso: isoHoursFromNow(6 + i * 6), endIso: isoHoursFromNow(7 + i * 6), }) } await loginAndOpenCalendar(page) // Each title should appear in the panel. for (const title of titles) { await expect .poll( async () => { const items = page.locator('.calendar-panel__item') const count = await items.count() for (let i = 0; i < count; i++) { const text = await items.nth(i).textContent() if (text && text.includes(title)) return true } return false }, { timeout: 15_000, intervals: [500] }, ) .toBe(true) } }) test('deleting an event via API syncs to the UI after reload', async ({ page }) => { const title = `E2E_DELETE_SYNC_${Date.now().toString(36)}` const event = await createEventViaApi({ title, startIso: isoHoursFromNow(8), endIso: isoHoursFromNow(9), }) await loginAndOpenCalendar(page) // Verify the event is visible first. await expect .poll( async () => { const items = page.locator('.calendar-panel__item') const count = await items.count() for (let i = 0; i < count; i++) { const text = await items.nth(i).textContent() if (text && text.includes(title)) return true } return false }, { timeout: 15_000, intervals: [500] }, ) .toBe(true) // Delete via API. await deleteEventViaApi(event.id) // Verify API no longer returns it. const eventsAfterDelete = await listEventsViaApi() expect(eventsAfterDelete.find((e) => e.id === event.id)).toBeUndefined() // Reload the page — the UI must reflect the deletion. After reload // the auth state is preserved (U1 covers this), and the page returns // to /agent/monitor where the workbench layout remounts. await page.reload() await expect(page).toHaveURL(/\/agent\/monitor/, { timeout: 15_000 }) // Wait for the QuadrantPanel tabs to render, then click 日历. await expect(page.locator('.quadrant-panel__tab').first()).toBeVisible({ timeout: 15_000, }) await page.locator('.quadrant-panel__tab', { hasText: '日历' }).click() await expect(page.locator('.calendar-panel')).toBeVisible({ timeout: 10_000 }) await expect(page.locator('.calendar-panel__loading')).toBeHidden({ timeout: 15_000, }) // The deleted event must NOT reappear. await expect .poll( async () => { const items = page.locator('.calendar-panel__item') const count = await items.count() for (let i = 0; i < count; i++) { const text = await items.nth(i).textContent() if (text && text.includes(title)) return true } return false }, { timeout: 10_000, intervals: [500] }, ) .toBe(false) }) })