fischer-agentkit/src/agentkit/server/frontend/e2e/calendar-data-consistency.s...

357 lines
13 KiB
TypeScript

/**
* 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<string> {
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<string> {
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<CalendarEvent> {
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<CalendarEvent[]> {
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<void> {
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<void> {
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<void> {
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)
})
})