diff --git a/frontend/cypress/e2e/pr27-features.cy.js b/frontend/cypress/e2e/pr27-features.cy.js index 1b0a22e..03356e5 100644 --- a/frontend/cypress/e2e/pr27-features.cy.js +++ b/frontend/cypress/e2e/pr27-features.cy.js @@ -266,4 +266,396 @@ describe('PR27 Feature Tests', () => { cy.contains('登录 / 注册').should('be.visible') }) }) + + // ------------------------------------------------------------------------- + // API: Case-insensitive username registration + // ------------------------------------------------------------------------- + describe('API: case-insensitive username registration', () => { + const CASE_USER = 'CypressCaseTest' + const CASE_PASS = 'test1234' + + // Cleanup before test + before(() => { + cy.request({ url: '/api/users', headers: authHeaders }).then(res => { + const leftover = res.body.find(u => + u.username.toLowerCase() === CASE_USER.toLowerCase() + ) + if (leftover) { + cy.request({ + method: 'DELETE', + url: `/api/users/${leftover.id || leftover._id}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + }) + + after(() => { + // Cleanup registered user + cy.request({ url: '/api/users', headers: authHeaders }).then(res => { + const user = res.body.find(u => + u.username.toLowerCase() === CASE_USER.toLowerCase() + ) + if (user) { + cy.request({ + method: 'DELETE', + url: `/api/users/${user.id || user._id}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + }) + + it('registers a user with mixed case', () => { + cy.request({ + method: 'POST', + url: '/api/register', + body: { username: CASE_USER, password: CASE_PASS }, + failOnStatusCode: false, + }).then(res => { + expect(res.status).to.eq(201) + expect(res.body.token).to.be.a('string') + }) + }) + + it('rejects registration with same username in different case', () => { + cy.request({ + method: 'POST', + url: '/api/register', + body: { username: CASE_USER.toLowerCase(), password: CASE_PASS }, + failOnStatusCode: false, + }).then(res => { + expect(res.status).to.eq(400) + }) + }) + + it('rejects registration with all-uppercase variant', () => { + cy.request({ + method: 'POST', + url: '/api/register', + body: { username: CASE_USER.toUpperCase(), password: CASE_PASS }, + failOnStatusCode: false, + }).then(res => { + expect(res.status).to.eq(400) + }) + }) + + it('allows case-insensitive login', () => { + cy.request({ + method: 'POST', + url: '/api/login', + body: { username: CASE_USER.toLowerCase(), password: CASE_PASS }, + failOnStatusCode: false, + }).then(res => { + expect(res.status).to.eq(200) + expect(res.body.token).to.be.a('string') + }) + }) + }) + + // ------------------------------------------------------------------------- + // API: One-time username change via PUT /api/me/username + // ------------------------------------------------------------------------- + describe('API: one-time username change', () => { + const RENAME_USER = 'cypress_rename_test' + const RENAME_PASS = 'rename1234' + let renameToken + let renameUserId + + before(() => { + // Cleanup leftovers + cy.request({ url: '/api/users', headers: authHeaders }).then(res => { + for (const name of [RENAME_USER, 'cypress_renamed']) { + const leftover = res.body.find(u => u.username.toLowerCase() === name) + if (leftover) { + cy.request({ + method: 'DELETE', + url: `/api/users/${leftover.id || leftover._id}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + } + }) + }) + + after(() => { + // Cleanup + if (renameUserId) { + cy.request({ + method: 'DELETE', + url: `/api/users/${renameUserId}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + + it('registers a user for rename test', () => { + cy.request({ + method: 'POST', + url: '/api/register', + body: { username: RENAME_USER, password: RENAME_PASS }, + }).then(res => { + expect(res.status).to.eq(201) + renameToken = res.body.token + + // Get user ID + cy.request({ url: '/api/users', headers: authHeaders }).then(listRes => { + const u = listRes.body.find(x => x.username === RENAME_USER) + renameUserId = u.id || u._id + }) + }) + }) + + it('GET /api/me returns username_changed=false initially', () => { + cy.request({ + url: '/api/me', + headers: { Authorization: `Bearer ${renameToken}` }, + }).then(res => { + expect(res.status).to.eq(200) + expect(res.body.username_changed).to.eq(false) + }) + }) + + it('renames username successfully the first time', () => { + cy.request({ + method: 'PUT', + url: '/api/me/username', + headers: { Authorization: `Bearer ${renameToken}` }, + body: { username: 'cypress_renamed' }, + }).then(res => { + expect(res.status).to.eq(200) + expect(res.body.ok).to.eq(true) + expect(res.body.username).to.eq('cypress_renamed') + }) + }) + + it('GET /api/me returns username_changed=true after rename', () => { + cy.request({ + url: '/api/me', + headers: { Authorization: `Bearer ${renameToken}` }, + }).then(res => { + expect(res.body.username_changed).to.eq(true) + }) + }) + + it('rejects second rename attempt', () => { + cy.request({ + method: 'PUT', + url: '/api/me/username', + headers: { Authorization: `Bearer ${renameToken}` }, + body: { username: 'cypress_another_name' }, + failOnStatusCode: false, + }).then(res => { + expect(res.status).to.eq(400) + }) + }) + }) + + // ------------------------------------------------------------------------- + // API: en_name auto-translation on recipe create (no explicit en_name) + // ------------------------------------------------------------------------- + describe('API: en_name auto-translation on create', () => { + let recipeId + + after(() => { + if (recipeId) { + cy.request({ + method: 'DELETE', + url: `/api/recipes/${recipeId}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + + it('auto-translates en_name when creating recipe without en_name', () => { + cy.request({ + method: 'POST', + url: '/api/recipes', + headers: authHeaders, + body: { + name: '排毒按摩', + ingredients: [{ oil_name: '薰衣草', drops: 3 }], + tags: [], + }, + }).then(res => { + expect(res.status).to.be.oneOf([200, 201]) + recipeId = res.body.id + + cy.request('/api/recipes').then(listRes => { + const found = listRes.body.find(r => r.id === recipeId) + expect(found).to.exist + expect(found.en_name).to.be.a('string') + expect(found.en_name.length).to.be.greaterThan(0) + // auto_translate('排毒按摩') should produce 'Detox Massage' + expect(found.en_name).to.include('Detox') + expect(found.en_name).to.include('Massage') + }) + }) + }) + }) + + // ------------------------------------------------------------------------- + // API: Recipe name change auto-retranslates en_name + // ------------------------------------------------------------------------- + describe('API: rename recipe auto-retranslates en_name', () => { + let recipeId + + after(() => { + if (recipeId) { + cy.request({ + method: 'DELETE', + url: `/api/recipes/${recipeId}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + + it('creates recipe with auto en_name, then renames to verify retranslation', () => { + cy.request({ + method: 'POST', + url: '/api/recipes', + headers: authHeaders, + body: { + name: '助眠喷雾', + ingredients: [{ oil_name: '薰衣草', drops: 5 }], + tags: [], + }, + }).then(res => { + recipeId = res.body.id + + // Verify initial auto-translation + cy.request('/api/recipes').then(listRes => { + const r = listRes.body.find(x => x.id === recipeId) + expect(r.en_name).to.include('Sleep') + + // Rename to completely different name + cy.request({ + method: 'PUT', + url: `/api/recipes/${recipeId}`, + headers: authHeaders, + body: { name: '肩颈按摩' }, + }).then(() => { + cy.request('/api/recipes').then(list2 => { + const r2 = list2.body.find(x => x.id === recipeId) + // Should now be retranslated to Neck & Shoulder Massage + expect(r2.en_name).to.include('Neck') + expect(r2.en_name).to.include('Massage') + // Should NOT contain Sleep anymore + expect(r2.en_name).to.not.include('Sleep') + }) + }) + }) + }) + }) + + it('does not retranslate when explicit en_name provided on update', () => { + cy.request({ + method: 'PUT', + url: `/api/recipes/${recipeId}`, + headers: authHeaders, + body: { name: '免疫配方', en_name: 'my custom name' }, + }).then(() => { + cy.request('/api/recipes').then(listRes => { + const r = listRes.body.find(x => x.id === recipeId) + expect(r.en_name).to.eq('My Custom Name') // title-cased + }) + }) + }) + }) + + // ------------------------------------------------------------------------- + // API: Delete user transfers diary to admin (with username appended) + // ------------------------------------------------------------------------- + describe('API: delete user diary transfer with username', () => { + const XFER_USER = 'cypress_xfer_test' + const XFER_PASS = 'xfer1234' + let xferUserId + let xferToken + + before(() => { + // Cleanup leftovers + cy.request({ url: '/api/users', headers: authHeaders }).then(res => { + const leftover = res.body.find(u => u.username === XFER_USER) + if (leftover) { + cy.request({ + method: 'DELETE', + url: `/api/users/${leftover.id || leftover._id}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + }) + + it('registers user, adds diary, deletes user, verifies transfer', () => { + // Register + cy.request({ + method: 'POST', + url: '/api/register', + body: { username: XFER_USER, password: XFER_PASS }, + }).then(regRes => { + xferToken = regRes.body.token + + // Get user id + cy.request({ url: '/api/users', headers: authHeaders }).then(listRes => { + const u = listRes.body.find(x => x.username === XFER_USER) + xferUserId = u.id || u._id + + const userAuth = { Authorization: `Bearer ${xferToken}` } + + // Add unique diary entry + cy.request({ + method: 'POST', + url: '/api/diary', + headers: userAuth, + body: { + name: 'PR28转移日记', + ingredients: [ + { oil: '檀香', drops: 7 }, + { oil: '岩兰草', drops: 3 }, + ], + note: '转移测试PR28', + }, + }).then(() => { + // Delete user + cy.request({ + method: 'DELETE', + url: `/api/users/${xferUserId}`, + headers: authHeaders, + }).then(delRes => { + expect(delRes.body.ok).to.eq(true) + + // Verify diary was transferred to admin with username appended + cy.request({ + url: '/api/diary', + headers: authHeaders, + }).then(diaryRes => { + const transferred = diaryRes.body.find( + d => d.name && d.name.includes('PR28转移日记') && d.name.includes(XFER_USER) + ) + expect(transferred).to.exist + expect(transferred.note).to.eq('转移测试PR28') + + // Cleanup + if (transferred) { + cy.request({ + method: 'DELETE', + url: `/api/diary/${transferred.id}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + }) + }) + }) + }) + }) + }) }) diff --git a/frontend/src/__tests__/pr27Features.test.js b/frontend/src/__tests__/pr27Features.test.js index c70f15a..aa41519 100644 --- a/frontend/src/__tests__/pr27Features.test.js +++ b/frontend/src/__tests__/pr27Features.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { recipeNameEn } from '../composables/useOilTranslation' +import { recipeNameEn, oilEn } from '../composables/useOilTranslation' // --------------------------------------------------------------------------- // EDITOR_ONLY_TAGS includes '已下架' @@ -169,4 +169,117 @@ describe('duplicate oil prevention', () => { const isDup = ings.some(i => i !== ing && i.oil === '薰衣草') expect(isDup).toBe(false) }) + + it('handles empty ingredient list (no duplicates)', () => { + const ings = [] + const isDup = ings.some(i => i.oil === '薰衣草') + expect(isDup).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// recipeNameEn — additional edge cases for PR28 +// --------------------------------------------------------------------------- +describe('recipeNameEn — PR28 additional cases', () => { + it('translates 排毒配方 → Detox Blend', () => { + expect(recipeNameEn('排毒配方')).toBe('Detox Blend') + }) + + it('translates 呼吸系统护理 → Respiratory System Care', () => { + expect(recipeNameEn('呼吸系统护理')).toBe('Respiratory System Care') + }) + + it('translates 儿童助眠 → Children\'s Sleep Aid', () => { + expect(recipeNameEn('儿童助眠')).toBe("Children's Sleep Aid") + }) + + it('translates 美容按摩 → Beauty Massage', () => { + expect(recipeNameEn('美容按摩')).toBe('Beauty Massage') + }) + + it('handles mixed Chinese and ASCII text', () => { + // Unknown Chinese chars are skipped; if ASCII appears, it's kept + const result = recipeNameEn('testBlend') + // No Chinese keyword matches, falls back to original + expect(result).toBe('testBlend') + }) + + it('handles single-keyword name', () => { + expect(recipeNameEn('免疫')).toBe('Immunity') + }) + + it('translates compound: 肩颈按摩配方 → Neck & Shoulder Massage Blend', () => { + expect(recipeNameEn('肩颈按摩配方')).toBe('Neck & Shoulder Massage Blend') + }) +}) + +// --------------------------------------------------------------------------- +// oilEn — English oil name translation +// --------------------------------------------------------------------------- +describe('oilEn', () => { + it('translates known oils', () => { + expect(oilEn('薰衣草')).toBe('Lavender') + expect(oilEn('茶树')).toBe('Tea Tree') + expect(oilEn('乳香')).toBe('Frankincense') + }) + + it('handles 复方 suffix removal', () => { + expect(oilEn('舒缓复方')).toBe('Past Tense') + }) + + it('handles 复方 suffix addition', () => { + // '呼吸' maps via '呼吸复方' → 'Breathe' + expect(oilEn('呼吸')).toBe('Breathe') + }) + + it('returns empty string for unknown oil', () => { + expect(oilEn('不存在的油')).toBe('') + }) +}) + +// --------------------------------------------------------------------------- +// Case-insensitive username logic (pure function) +// --------------------------------------------------------------------------- +describe('case-insensitive username matching', () => { + const matchCaseInsensitive = (input, existing) => + existing.some(u => u.toLowerCase() === input.toLowerCase()) + + it('detects duplicate usernames case-insensitively', () => { + const existing = ['TestUser', 'Alice', 'Bob'] + expect(matchCaseInsensitive('testuser', existing)).toBe(true) + expect(matchCaseInsensitive('TESTUSER', existing)).toBe(true) + expect(matchCaseInsensitive('TestUser', existing)).toBe(true) + }) + + it('allows unique username', () => { + const existing = ['TestUser', 'Alice'] + expect(matchCaseInsensitive('Charlie', existing)).toBe(false) + }) + + it('is case-insensitive for mixed-case inputs', () => { + const existing = ['alice'] + expect(matchCaseInsensitive('Alice', existing)).toBe(true) + expect(matchCaseInsensitive('ALICE', existing)).toBe(true) + expect(matchCaseInsensitive('aLiCe', existing)).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// One-time username change logic +// --------------------------------------------------------------------------- +describe('one-time username change guard', () => { + it('blocks rename when username_changed is truthy', () => { + const user = { username_changed: 1 } + expect(!!user.username_changed).toBe(true) + }) + + it('allows rename when username_changed is falsy', () => { + const user = { username_changed: 0 } + expect(!!user.username_changed).toBe(false) + }) + + it('allows rename when username_changed is undefined', () => { + const user = {} + expect(!!user.username_changed).toBe(false) + }) })