357 lines
13 KiB
TypeScript
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)
|
|
})
|
|
})
|