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:
99
frontend/cypress/e2e/bug-tracker-flow.cy.js
Normal file
99
frontend/cypress/e2e/bug-tracker-flow.cy.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
216
frontend/cypress/e2e/diary-flow.cy.js
Normal file
216
frontend/cypress/e2e/diary-flow.cy.js
Normal 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
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
57
frontend/cypress/e2e/inventory-flow.cy.js
Normal file
57
frontend/cypress/e2e/inventory-flow.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
101
frontend/cypress/e2e/manage-recipes.cy.js
Normal file
101
frontend/cypress/e2e/manage-recipes.cy.js
Normal 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()
|
||||
})
|
||||
})
|
||||
88
frontend/cypress/e2e/recipe-cost-parity.cy.js
Normal file
88
frontend/cypress/e2e/recipe-cost-parity.cy.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
239
frontend/cypress/e2e/user-management-flow.cy.js
Normal file
239
frontend/cypress/e2e/user-management-flow.cy.js
Normal 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
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user