Add comprehensive test suite: 105 unit + 167 E2E tests

- Vitest unit tests: smart paste parsing (37), cost calculations (21),
  oil translation (16), dialog system (12), with production data fixtures
- Cypress E2E tests: API CRUD (27), auth flow (8), recipe detail (10),
  search (12), oil reference (4), favorites (6), inventory (6),
  recipe management (10), diary (11), bug tracker (8), user management (13),
  cost parity (6), data integrity (8), responsive (9), performance (6),
  navigation (8), admin flow (5)
- Test coverage doc with prioritized gap analysis
- Found backend bug: POST /api/bug-reports/{id}/comment deletes the bug

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 19:47:47 +00:00
parent ee8ec23dc7
commit 2491479c2c
16 changed files with 3232 additions and 3 deletions

View File

@@ -0,0 +1,99 @@
describe('Bug Tracker Flow', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
const TEST_CONTENT = 'Cypress_E2E_Bug_测试缺陷_' + Date.now()
let testBugId = null
describe('API: bug lifecycle', () => {
it('submits a new bug via API', () => {
cy.request({
method: 'POST',
url: '/api/bug-report',
headers: authHeaders,
body: { content: TEST_CONTENT, priority: 2 }
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
it('verifies the bug appears in the list', () => {
cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => {
expect(res.body).to.be.an('array')
const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug'))
expect(found).to.exist
testBugId = found.id
})
})
it('updates bug status to testing', () => {
cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => {
const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug'))
testBugId = found.id
cy.request({
method: 'PUT',
url: `/api/bug-reports/${testBugId}`,
headers: authHeaders,
body: { status: 1, note: 'E2E test status change' }
}).then(r => expect(r.status).to.eq(200))
})
})
it('verifies status was updated', () => {
cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => {
const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug'))
expect(found.is_resolved).to.eq(1)
})
})
// NOTE: POST /api/bug-reports/{id}/comment has a backend bug — the decorator
// is stacked on delete_bug function, so POST to /comment actually deletes the bug.
// Skipping comment tests until backend is fixed.
it('bug has auto-generated creation comment', () => {
cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => {
const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug'))
expect(found).to.exist
expect(found.comments).to.be.an('array')
expect(found.comments.length).to.be.gte(1) // auto creation log
})
})
it('deletes the test bug', () => {
cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => {
const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug'))
if (found) {
cy.request({
method: 'DELETE',
url: `/api/bug-reports/${found.id}`,
headers: authHeaders
}).then(r => expect(r.status).to.eq(200))
}
})
})
it('verifies the bug is deleted', () => {
cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => {
const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug'))
expect(found).to.not.exist
})
})
})
describe('UI: bugs page', () => {
it('visits /bugs and page renders', () => {
cy.visit('/bugs', {
onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) }
})
cy.contains('Bug', { timeout: 10000 }).should('be.visible')
})
})
after(() => {
cy.request({ url: '/api/bug-reports', headers: authHeaders, failOnStatusCode: false }).then(res => {
if (res.status === 200 && Array.isArray(res.body)) {
res.body.filter(b => b.content && b.content.includes('Cypress_E2E_Bug')).forEach(bug => {
cy.request({ method: 'DELETE', url: `/api/bug-reports/${bug.id}`, headers: authHeaders, failOnStatusCode: false })
})
}
})
})
})

View File

@@ -0,0 +1,216 @@
describe('Diary Flow', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
let testDiaryId = null
describe('API: full diary lifecycle', () => {
it('creates a diary entry via API', () => {
cy.request({
method: 'POST',
url: '/api/diary',
headers: authHeaders,
body: {
name: 'Cypress_Diary_Test_日记',
ingredients: [
{ oil: '薰衣草', drops: 3 },
{ oil: '茶树', drops: 2 }
],
note: '这是E2E测试创建的日记'
}
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
testDiaryId = res.body.id || res.body._id
expect(testDiaryId).to.exist
})
})
it('verifies diary entry appears in GET /api/diary', () => {
cy.request({
url: '/api/diary',
headers: authHeaders
}).then(res => {
expect(res.body).to.be.an('array')
const found = res.body.find(d => d.name === 'Cypress_Diary_Test_日记')
expect(found).to.exist
expect(found.ingredients).to.have.length(2)
expect(found.note).to.eq('这是E2E测试创建的日记')
testDiaryId = found.id || found._id
})
})
it('updates the diary entry via PUT', () => {
cy.request({
url: '/api/diary',
headers: authHeaders
}).then(res => {
const found = res.body.find(d => d.name === 'Cypress_Diary_Test_日记')
testDiaryId = found.id || found._id
cy.request({
method: 'PUT',
url: `/api/diary/${testDiaryId}`,
headers: authHeaders,
body: {
name: 'Cypress_Diary_Updated_日记',
ingredients: [
{ oil: '薰衣草', drops: 5 },
{ oil: '乳香', drops: 3 }
],
note: '已更新的日记'
}
}).then(res => {
expect(res.status).to.eq(200)
})
})
})
it('verifies the update took effect', () => {
cy.request({
url: '/api/diary',
headers: authHeaders
}).then(res => {
const found = res.body.find(d => d.name === 'Cypress_Diary_Updated_日记')
expect(found).to.exist
expect(found.note).to.eq('已更新的日记')
expect(found.ingredients).to.have.length(2)
testDiaryId = found.id || found._id
})
})
it('adds a journal entry to the diary', () => {
cy.request({
url: '/api/diary',
headers: authHeaders
}).then(res => {
const found = res.body.find(d => d.name === 'Cypress_Diary_Updated_日记')
testDiaryId = found.id || found._id
cy.request({
method: 'POST',
url: `/api/diary/${testDiaryId}/entries`,
headers: authHeaders,
body: {
content: 'Cypress测试日志: 使用后感觉很好'
}
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
})
it('verifies journal entry exists in diary', () => {
cy.request({
url: '/api/diary',
headers: authHeaders
}).then(res => {
const found = res.body.find(d => d.name === 'Cypress_Diary_Updated_日记')
expect(found).to.exist
expect(found.entries).to.be.an('array')
expect(found.entries.length).to.be.gte(1)
const entry = found.entries.find(e =>
(e.text || e.content || '').includes('Cypress测试日志')
)
expect(entry).to.exist
})
})
it('deletes the journal entry', () => {
cy.request({
url: '/api/diary',
headers: authHeaders
}).then(res => {
const found = res.body.find(d => d.name === 'Cypress_Diary_Updated_日记')
const entry = found.entries.find(e =>
(e.text || e.content || '').includes('Cypress测试日志')
)
const entryId = entry.id || entry._id
cy.request({
method: 'DELETE',
url: `/api/diary/entries/${entryId}`,
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
})
})
it('deletes the diary entry', () => {
cy.request({
url: '/api/diary',
headers: authHeaders
}).then(res => {
const found = res.body.find(d => d.name === 'Cypress_Diary_Updated_日记')
if (found) {
const id = found.id || found._id
cy.request({
method: 'DELETE',
url: `/api/diary/${id}`,
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
}
})
})
it('verifies diary entry is gone', () => {
cy.request({
url: '/api/diary',
headers: authHeaders
}).then(res => {
const found = res.body.find(d =>
d.name === 'Cypress_Diary_Updated_日记' || d.name === 'Cypress_Diary_Test_日记'
)
expect(found).to.not.exist
})
})
})
describe('UI: diary page renders', () => {
it('visits /mydiary and verifies page renders', () => {
cy.visit('/mydiary', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.my-diary', { timeout: 10000 }).should('exist')
// Should show diary sub-tabs
cy.get('.sub-tab').should('have.length', 3)
cy.contains('配方日记').should('be.visible')
cy.contains('Brand').should('be.visible')
cy.contains('Account').should('be.visible')
})
it('diary grid is visible on diary tab', () => {
cy.visit('/mydiary', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.my-diary', { timeout: 10000 }).should('exist')
// Diary grid or empty hint should be present
cy.get('.diary-grid, .empty-hint').should('exist')
})
})
// Safety cleanup in case tests fail mid-way
after(() => {
cy.request({
url: '/api/diary',
headers: authHeaders,
failOnStatusCode: false
}).then(res => {
if (res.status === 200 && Array.isArray(res.body)) {
const testEntries = res.body.filter(d =>
d.name && (d.name.includes('Cypress_Diary_Test') || d.name.includes('Cypress_Diary_Updated'))
)
testEntries.forEach(entry => {
cy.request({
method: 'DELETE',
url: `/api/diary/${entry.id || entry._id}`,
headers: authHeaders,
failOnStatusCode: false
})
})
}
})
})
})

View File

@@ -0,0 +1,57 @@
describe('Inventory Flow', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
const TEST_OIL = '薰衣草'
describe('API: inventory CRUD', () => {
it('adds an oil to inventory', () => {
cy.request({
method: 'POST',
url: '/api/inventory',
headers: authHeaders,
body: { oil_name: TEST_OIL }
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
it('reads inventory and sees the oil', () => {
cy.request({ url: '/api/inventory', headers: authHeaders }).then(res => {
expect(res.body).to.be.an('array')
expect(res.body).to.include(TEST_OIL)
})
})
it('gets matching recipes for inventory', () => {
cy.request({ url: '/api/inventory/recipes', headers: authHeaders }).then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('removes the oil from inventory', () => {
cy.request({
method: 'DELETE',
url: `/api/inventory/${encodeURIComponent(TEST_OIL)}`,
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
})
it('verifies oil is removed', () => {
cy.request({ url: '/api/inventory', headers: authHeaders }).then(res => {
expect(res.body).to.not.include(TEST_OIL)
})
})
})
describe('UI: inventory page', () => {
it('page loads with oil picker', () => {
cy.visit('/inventory', {
onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) }
})
cy.contains('库存', { timeout: 10000 }).should('be.visible')
})
})
})

View File

@@ -0,0 +1,101 @@
describe('Manage Recipes Page', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
beforeEach(() => {
cy.visit('/manage', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
// Wait for the recipe manager to load
cy.get('.recipe-manager', { timeout: 10000 }).should('exist')
})
it('loads and shows recipe lists', () => {
// Should show public recipes section with at least some recipes
cy.contains('公共配方库').should('be.visible')
cy.get('.recipe-row').should('have.length.gte', 1)
})
it('search box filters recipes', () => {
cy.get('.recipe-row').then($rows => {
const initialCount = $rows.length
// Type a search term
cy.get('.manage-toolbar .search-input').type('薰衣草')
cy.wait(500)
// Filtered count should be different (fewer or equal)
cy.get('.recipe-row').should('have.length.lte', initialCount)
})
})
it('clearing search restores all recipes', () => {
cy.get('.manage-toolbar .search-input').type('薰衣草')
cy.wait(500)
cy.get('.recipe-row').then($filtered => {
const filteredCount = $filtered.length
cy.get('.manage-toolbar .search-input').clear()
cy.wait(500)
cy.get('.recipe-row').should('have.length.gte', filteredCount)
})
})
it('can click a recipe to open the editor overlay', () => {
// Click the row-info area (which triggers editRecipe)
cy.get('.recipe-row .row-info').first().click()
// Editor overlay should appear
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
cy.contains('编辑配方').should('be.visible')
// Should have form fields
cy.get('.form-group').should('have.length.gte', 1)
})
it('editor shows ingredients table with oil selects', () => {
cy.get('.recipe-row .row-info').first().click()
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
// Ingredients section should have rows with select dropdowns
cy.get('.overlay-panel .ing-row').should('have.length.gte', 1)
cy.get('.overlay-panel .form-select').should('have.length.gte', 1)
cy.get('.overlay-panel .form-input-sm').should('have.length.gte', 1)
})
it('can close the editor overlay', () => {
cy.get('.recipe-row .row-info').first().click()
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
// Close via the close button
cy.get('.overlay-panel .btn-close').click()
cy.get('.overlay-panel').should('not.exist')
})
it('can close the editor with cancel button', () => {
cy.get('.recipe-row .row-info').first().click()
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
cy.get('.overlay-panel').contains('取消').click()
cy.get('.overlay-panel').should('not.exist')
})
it('tag filter bar toggles', () => {
// Look for any tag-related toggle button
cy.get('body').then($body => {
const hasToggle = $body.find('.tag-toggle-btn, [class*="tag-filter"] button, button:contains("标签")').length > 0
if (hasToggle) {
cy.get('.tag-toggle-btn, [class*="tag-filter"] button, button').contains('标签').first().click()
cy.wait(500)
// Tag area should exist after toggle
cy.get('[class*="tag"]').should('exist')
}
})
})
it('shows recipe cost in each row', () => {
cy.get('.row-cost').first().should('not.be.empty')
cy.get('.row-cost').first().invoke('text').should('contain', '¥')
})
it('has add recipe button that opens overlay', () => {
cy.get('.manage-toolbar').contains('添加配方').click()
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
cy.contains('添加配方').should('be.visible')
// Close it
cy.get('.overlay-panel .btn-close').click()
})
})

View File

@@ -0,0 +1,88 @@
describe('Recipe Cost Parity Test', () => {
// Verify recipe cost formula: cost = sum(bottle_price / drop_count * drops)
let oilsMap = {}
let testRecipes = []
before(() => {
cy.request('/api/oils').then(res => {
res.body.forEach(oil => {
oilsMap[oil.name] = {
bottle_price: oil.bottle_price,
drop_count: oil.drop_count,
ppd: oil.drop_count ? oil.bottle_price / oil.drop_count : 0,
retail_price: oil.retail_price
}
})
})
cy.request('/api/recipes').then(res => {
testRecipes = res.body.slice(0, 20)
})
})
it('oil data has correct structure (137+ oils)', () => {
expect(Object.keys(oilsMap).length).to.be.gte(100)
const lav = oilsMap['薰衣草']
expect(lav).to.exist
expect(lav.bottle_price).to.be.gt(0)
expect(lav.drop_count).to.be.gt(0)
})
it('price-per-drop matches formula for common oils', () => {
const checks = ['薰衣草', '乳香', '茶树', '柠檬', '椒样薄荷']
checks.forEach(name => {
const oil = oilsMap[name]
if (oil) {
const expected = oil.bottle_price / oil.drop_count
expect(oil.ppd).to.be.closeTo(expected, 0.0001)
}
})
})
it('calculates cost for each of first 20 recipes', () => {
testRecipes.forEach(recipe => {
let cost = 0
recipe.ingredients.forEach(ing => {
const oil = oilsMap[ing.oil_name]
if (oil) cost += oil.ppd * ing.drops
})
expect(cost).to.be.gte(0)
})
})
it('retail price >= wholesale for oils that have it', () => {
Object.entries(oilsMap).forEach(([name, oil]) => {
if (oil.retail_price && oil.retail_price > 0) {
expect(oil.retail_price).to.be.gte(oil.bottle_price)
}
})
})
it('no recipe has all-zero cost', () => {
let zeroCostCount = 0
testRecipes.forEach(recipe => {
let cost = 0
recipe.ingredients.forEach(ing => {
const oil = oilsMap[ing.oil_name]
if (oil) cost += oil.ppd * ing.drops
})
if (cost === 0) zeroCostCount++
})
expect(zeroCostCount).to.be.lt(testRecipes.length)
})
it('cost formula is consistent: two calculation methods agree', () => {
testRecipes.forEach(recipe => {
const costs = recipe.ingredients.map(ing => {
const oil = oilsMap[ing.oil_name]
return oil ? oil.ppd * ing.drops : 0
})
const fromMap = costs.reduce((a, b) => a + b, 0)
const fromReduce = recipe.ingredients.reduce((s, ing) => {
const oil = oilsMap[ing.oil_name]
return s + (oil ? oil.ppd * ing.drops : 0)
}, 0)
expect(fromMap).to.be.closeTo(fromReduce, 0.001)
})
})
})

View File

@@ -0,0 +1,239 @@
describe('User Management Flow', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
const TEST_USERNAME = 'cypress_test_user_e2e'
const TEST_DISPLAY_NAME = 'Cypress E2E Test User'
let testUserId = null
describe('API: user lifecycle', () => {
// Cleanup any leftover test user first
before(() => {
cy.request({
url: '/api/users',
headers: authHeaders
}).then(res => {
const leftover = res.body.find(u => u.username === TEST_USERNAME)
if (leftover) {
cy.request({
method: 'DELETE',
url: `/api/users/${leftover.id || leftover._id}`,
headers: authHeaders,
failOnStatusCode: false
})
}
})
})
it('creates a new test user via API', () => {
cy.request({
method: 'POST',
url: '/api/users',
headers: authHeaders,
body: {
username: TEST_USERNAME,
display_name: TEST_DISPLAY_NAME,
role: 'viewer'
}
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
testUserId = res.body.id || res.body._id
// Should return a token for the new user
if (res.body.token) {
expect(res.body.token).to.be.a('string')
}
})
})
it('verifies the user appears in the user list', () => {
cy.request({
url: '/api/users',
headers: authHeaders
}).then(res => {
expect(res.body).to.be.an('array')
const found = res.body.find(u => u.username === TEST_USERNAME)
expect(found).to.exist
expect(found.display_name).to.eq(TEST_DISPLAY_NAME)
expect(found.role).to.eq('viewer')
testUserId = found.id || found._id
})
})
it('updates user role to editor', () => {
cy.request({
url: '/api/users',
headers: authHeaders
}).then(res => {
const found = res.body.find(u => u.username === TEST_USERNAME)
testUserId = found.id || found._id
cy.request({
method: 'PUT',
url: `/api/users/${testUserId}`,
headers: authHeaders,
body: { role: 'editor' }
}).then(res => {
expect(res.status).to.eq(200)
})
})
})
it('verifies role was updated', () => {
cy.request({
url: '/api/users',
headers: authHeaders
}).then(res => {
const found = res.body.find(u => u.username === TEST_USERNAME)
expect(found.role).to.eq('editor')
})
})
it('deletes the test user', () => {
cy.request({
url: '/api/users',
headers: authHeaders
}).then(res => {
const found = res.body.find(u => u.username === TEST_USERNAME)
if (found) {
testUserId = found.id || found._id
cy.request({
method: 'DELETE',
url: `/api/users/${testUserId}`,
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
}
})
})
it('verifies the user is deleted', () => {
cy.request({
url: '/api/users',
headers: authHeaders
}).then(res => {
const found = res.body.find(u => u.username === TEST_USERNAME)
expect(found).to.not.exist
})
})
})
describe('UI: users page renders', () => {
it('visits /users and verifies page structure', () => {
cy.visit('/users', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.user-management', { timeout: 10000 }).should('exist')
cy.contains('用户管理').should('be.visible')
})
it('shows search input and role filter buttons', () => {
cy.visit('/users', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.user-management', { timeout: 10000 }).should('exist')
// Search box
cy.get('.search-input').should('exist')
// Role filter buttons
cy.get('.filter-btn').should('have.length.gte', 1)
cy.get('.filter-btn').contains('管理员').should('exist')
cy.get('.filter-btn').contains('编辑').should('exist')
cy.get('.filter-btn').contains('查看者').should('exist')
})
it('displays user list with user cards', () => {
cy.visit('/users', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.user-management', { timeout: 10000 }).should('exist')
cy.get('.user-card', { timeout: 5000 }).should('have.length.gte', 1)
// Each card shows name and role
cy.get('.user-card').first().within(() => {
cy.get('.user-name').should('not.be.empty')
cy.get('.user-role-badge').should('exist')
})
})
it('search filters users', () => {
cy.visit('/users', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.user-management', { timeout: 10000 }).should('exist')
cy.get('.user-card').then($cards => {
const total = $cards.length
// Search for something specific
cy.get('.search-input').type('admin')
cy.wait(300)
cy.get('.user-card').should('have.length.lte', total)
})
})
it('role filter narrows user list', () => {
cy.visit('/users', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.user-management', { timeout: 10000 }).should('exist')
cy.get('.user-card').then($cards => {
const total = $cards.length
// Click a role filter
cy.get('.filter-btn').contains('管理员').click()
cy.wait(300)
cy.get('.user-card').should('have.length.lte', total)
// Clicking again deactivates the filter
cy.get('.filter-btn').contains('管理员').click()
cy.wait(300)
cy.get('.user-card').should('have.length', total)
})
})
it('shows user count', () => {
cy.visit('/users', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.user-management', { timeout: 10000 }).should('exist')
cy.get('.user-count').should('contain', '个用户')
})
it('has create user section', () => {
cy.visit('/users', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.user-management', { timeout: 10000 }).should('exist')
cy.get('.create-section').should('exist')
cy.contains('创建新用户').should('be.visible')
})
})
// Safety cleanup
after(() => {
cy.request({
url: '/api/users',
headers: authHeaders,
failOnStatusCode: false
}).then(res => {
if (res.status === 200 && Array.isArray(res.body)) {
const testUsers = res.body.filter(u => u.username === TEST_USERNAME)
testUsers.forEach(user => {
cy.request({
method: 'DELETE',
url: `/api/users/${user.id || user._id}`,
headers: authHeaders,
failOnStatusCode: false
})
})
}
})
})
})