diff --git a/.gitignore b/.gitignore index 847cb22..8be5a46 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ backups/ frontend/node_modules/ frontend/dist/ frontend/.vite/ +.vite/ +data/ +test-results/ diff --git a/backend/main.py b/backend/main.py index 69d872e..e727656 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,12 +6,18 @@ import json import os from backend.database import get_db, init_db, seed_defaults, log_audit +from backend.translate import auto_translate import hashlib import secrets as _secrets app = FastAPI(title="Essential Oil Formula Calculator API") +def title_case(s: str) -> str: + """Convert to title case: 'pain relief' → 'Pain Relief'""" + return s.strip().title() if s else s + + # ── Password hashing (PBKDF2-SHA256, stdlib) ───────── def hash_password(password: str) -> str: salt = _secrets.token_hex(16) @@ -93,6 +99,7 @@ class RecipeIn(BaseModel): note: str = "" ingredients: list[IngredientIn] tags: list[str] = [] + en_name: Optional[str] = None class RecipeUpdate(BaseModel): @@ -693,7 +700,7 @@ def upsert_oil(oil: OilIn, user=Depends(require_role("admin", "senior_editor"))) "ON CONFLICT(name) DO UPDATE SET bottle_price=excluded.bottle_price, drop_count=excluded.drop_count, " "retail_price=excluded.retail_price, en_name=COALESCE(excluded.en_name, oils.en_name), " "is_active=COALESCE(excluded.is_active, oils.is_active)", - (oil.name, oil.bottle_price, oil.drop_count, oil.retail_price, oil.en_name, oil.is_active), + (oil.name, oil.bottle_price, oil.drop_count, oil.retail_price, title_case(oil.en_name) if oil.en_name else oil.en_name, oil.is_active), ) log_audit(conn, user["id"], "upsert_oil", "oil", oil.name, oil.name, json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count})) @@ -789,8 +796,9 @@ def create_recipe(recipe: RecipeIn, user=Depends(get_current_user)): admin = c.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone() if admin: owner_id = admin["id"] - c.execute("INSERT INTO recipes (name, note, owner_id) VALUES (?, ?, ?)", - (recipe.name, recipe.note, owner_id)) + en_name = title_case(recipe.en_name) if recipe.en_name else auto_translate(recipe.name) + c.execute("INSERT INTO recipes (name, note, owner_id, en_name) VALUES (?, ?, ?, ?)", + (recipe.name, recipe.note, owner_id, en_name)) rid = c.lastrowid for ing in recipe.ingredients: c.execute( @@ -850,10 +858,13 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current if update.name is not None: c.execute("UPDATE recipes SET name = ? WHERE id = ?", (update.name, recipe_id)) + # Re-translate en_name if name changed and no explicit en_name provided + if update.en_name is None: + c.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (auto_translate(update.name), recipe_id)) if update.note is not None: c.execute("UPDATE recipes SET note = ? WHERE id = ?", (update.note, recipe_id)) if update.en_name is not None: - c.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (update.en_name, recipe_id)) + c.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (title_case(update.en_name), recipe_id)) if update.ingredients is not None: c.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,)) for ing in update.ingredients: @@ -916,6 +927,10 @@ def adopt_recipe(recipe_id: int, user=Depends(require_role("admin"))): return {"ok": True, "msg": "already owned"} old_owner = conn.execute("SELECT id, role, display_name, username FROM users WHERE id = ?", (row["owner_id"],)).fetchone() old_name = (old_owner["display_name"] or old_owner["username"]) if old_owner else "unknown" + # Auto-fill en_name if missing + existing_en = conn.execute("SELECT en_name FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + if not existing_en["en_name"]: + conn.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (auto_translate(row["name"]), recipe_id)) conn.execute("UPDATE recipes SET owner_id = ?, updated_by = ? WHERE id = ?", (user["id"], user["id"], recipe_id)) log_audit(conn, user["id"], "adopt_recipe", "recipe", recipe_id, row["name"], json.dumps({"from_user": old_name})) @@ -1096,10 +1111,45 @@ def delete_user(user_id: int, user=Depends(require_role("admin"))): if not target: conn.close() raise HTTPException(404, "User not found") + # Transfer personal diary recipes to admin before deletion (skip duplicates) + target_name = target["display_name"] or target["username"] + diaries = conn.execute("SELECT id, name, ingredients FROM user_diary WHERE user_id = ?", (user_id,)).fetchall() + transferred = 0 + if diaries: + # Build set of ingredient fingerprints from admin diary + public recipes + def _ings_key(ings_json): + """Normalize ingredients to a comparable key.""" + try: + ings = json.loads(ings_json) if isinstance(ings_json, str) else [] + return tuple(sorted((i.get("oil") or i.get("oil_name", ""), i.get("drops", 0)) for i in ings)) + except Exception: + return () + existing_keys = set() + admin_diaries = conn.execute("SELECT ingredients FROM user_diary WHERE user_id = ?", (user["id"],)).fetchall() + for row in admin_diaries: + existing_keys.add(_ings_key(row["ingredients"])) + public_recipes = conn.execute("SELECT id FROM recipes").fetchall() + for pr in public_recipes: + pub_ings = conn.execute("SELECT oil_name, drops FROM recipe_ingredients WHERE recipe_id = ?", (pr["id"],)).fetchall() + existing_keys.add(tuple(sorted((r["oil_name"], r["drops"]) for r in pub_ings))) + for d in diaries: + d_key = _ings_key(d["ingredients"]) + is_dup = d_key in existing_keys and d_key != () + if is_dup: + conn.execute("DELETE FROM user_diary WHERE id = ?", (d["id"],)) + else: + new_name = f"{d['name']}({target_name})" + conn.execute("UPDATE user_diary SET user_id = ?, name = ? WHERE id = ?", + (user["id"], new_name, d["id"])) + transferred += 1 snapshot = dict(target) conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) + detail = dict(snapshot) + if diaries: + detail["transferred_diary_count"] = transferred + detail["skipped_duplicate_count"] = len(diaries) - transferred log_audit(conn, user["id"], "delete_user", "user", user_id, target["username"], - json.dumps(snapshot, ensure_ascii=False)) + json.dumps(detail, ensure_ascii=False)) conn.commit() conn.close() return {"ok": True} @@ -1598,11 +1648,14 @@ def my_contribution(user=Depends(get_current_user)): (f'%"from_user": "{display}"%',) ).fetchall() adopted_names = list(set(r["target_name"] for r in adopted_rows if r["target_name"])) - # pending: recipes still owned by user in public library - pending_rows = conn.execute( - "SELECT name FROM recipes WHERE owner_id = ?", (user["id"],) - ).fetchall() - pending_names = [r["name"] for r in pending_rows] + # pending: recipes still owned by user in public library (skip admin — admin owns all public recipes) + if user.get("role") == "admin": + pending_names = [] + else: + pending_rows = conn.execute( + "SELECT name FROM recipes WHERE owner_id = ?", (user["id"],) + ).fetchall() + pending_names = [r["name"] for r in pending_rows] # rejected: unique recipe names rejected (not already adopted or pending) rejected_rows = conn.execute( "SELECT DISTINCT target_name FROM audit_log WHERE action = 'reject_recipe' AND detail LIKE ?", @@ -1811,6 +1864,16 @@ def startup(): data = json.load(f) seed_defaults(data["oils_meta"], data["recipes"]) + # Auto-fill missing en_name for existing recipes + conn = get_db() + missing = conn.execute("SELECT id, name FROM recipes WHERE en_name IS NULL OR en_name = ''").fetchall() + for row in missing: + conn.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (auto_translate(row["name"]), row["id"])) + if missing: + conn.commit() + print(f"[INIT] Auto-translated {len(missing)} recipe names to English") + conn.close() + if os.path.isdir(FRONTEND_DIR): # Serve static assets (js/css/images) directly app.mount("/assets", StaticFiles(directory=os.path.join(FRONTEND_DIR, "assets")), name="assets") diff --git a/backend/test_translate.py b/backend/test_translate.py new file mode 100644 index 0000000..21e4559 --- /dev/null +++ b/backend/test_translate.py @@ -0,0 +1,108 @@ +"""Tests for translate.py auto_translate and main.py title_case.""" +import pytest +from backend.translate import auto_translate + + +# --------------------------------------------------------------------------- +# title_case (inlined here since it's a trivial helper in main.py) +# --------------------------------------------------------------------------- +def title_case(s: str) -> str: + return s.strip().title() if s else s + + +class TestTitleCase: + def test_basic(self): + assert title_case("pain relief") == "Pain Relief" + + def test_single_word(self): + assert title_case("sleep") == "Sleep" + + def test_preserves_already_cased(self): + assert title_case("Pain Relief") == "Pain Relief" + + def test_empty_string(self): + assert title_case("") == "" + + def test_none(self): + assert title_case(None) is None + + def test_strips_whitespace(self): + assert title_case(" hello world ") == "Hello World" + + +# --------------------------------------------------------------------------- +# auto_translate +# --------------------------------------------------------------------------- +class TestAutoTranslate: + def test_empty_string(self): + assert auto_translate("") == "" + + def test_single_keyword(self): + assert auto_translate("失眠") == "Insomnia" + + def test_compound_name(self): + result = auto_translate("助眠配方") + assert "Sleep" in result + assert "Blend" in result + + def test_head_pain(self): + result = auto_translate("头痛") + # 头痛 is a single keyword → Headache + assert "Headache" in result + + def test_shoulder_neck_massage(self): + result = auto_translate("肩颈按摩") + assert "Neck" in result or "Shoulder" in result + assert "Massage" in result + + def test_no_duplicate_words(self): + # 肩颈 → "Neck & Shoulder", but should not duplicate if sub-keys match + result = auto_translate("肩颈护理") + words = result.split() + # No exact duplicate consecutive words + for i in range(len(words) - 1): + if words[i] == words[i + 1]: + pytest.fail(f"Duplicate word '{words[i]}' in '{result}'") + + def test_skincare_blend(self): + result = auto_translate("皮肤修复") + assert "Skin" in result + assert "Repair" in result + + def test_foot_soak(self): + result = auto_translate("泡脚配方") + assert "Foot Soak" in result or "Foot" in result + + def test_ascii_passthrough(self): + # Embedded ASCII letters are preserved + result = auto_translate("DIY面膜") + assert "DIY" in result or "Diy" in result + assert "Face Mask" in result or "Mask" in result + + def test_pure_chinese_returns_english(self): + result = auto_translate("薰衣草精华") + # Should not return original Chinese; should have English words + assert any(c.isascii() and c.isalpha() for c in result) + + def test_fallback_for_unknown(self): + # Completely unknown chars get skipped; if nothing matches, returns original + result = auto_translate("㊗㊗㊗") + assert result == "㊗㊗㊗" + + def test_children_sleep(self): + result = auto_translate("儿童助眠") + assert "Children" in result + assert "Sleep" in result + + def test_menstrual_pain(self): + result = auto_translate("痛经调理") + assert "Menstrual Pain" in result or "Menstrual" in result + assert "Therapy" in result + + def test_result_is_title_cased(self): + result = auto_translate("排毒按摩") + # Each word should start with uppercase + for word in result.split(): + if word == "&": + continue + assert word[0].isupper(), f"'{word}' in '{result}' is not title-cased" diff --git a/backend/translate.py b/backend/translate.py new file mode 100644 index 0000000..c7f3fdc --- /dev/null +++ b/backend/translate.py @@ -0,0 +1,112 @@ +"""Auto-translate Chinese recipe names to English using keyword dictionary.""" + +# Common keywords in essential oil recipe names +_KEYWORDS = { + # Body parts + '头': 'Head', '头疗': 'Scalp Therapy', '头皮': 'Scalp', '头发': 'Hair', + '脸': 'Face', '面部': 'Face', '眼': 'Eye', '眼部': 'Eye', + '鼻': 'Nose', '鼻腔': 'Nasal', '耳': 'Ear', + '颈': 'Neck', '颈椎': 'Cervical', '肩': 'Shoulder', '肩颈': 'Neck & Shoulder', + '背': 'Back', '腰': 'Lower Back', '腰椎': 'Lumbar', + '胸': 'Chest', '腹': 'Abdomen', '腹部': 'Abdominal', + '手': 'Hand', '脚': 'Foot', '足': 'Foot', '膝': 'Knee', '关节': 'Joint', + '皮肤': 'Skin', '肌肤': 'Skin', '毛孔': 'Pore', + '乳腺': 'Breast', '子宫': 'Uterine', '私密': 'Intimate', + '淋巴': 'Lymph', '肝': 'Liver', '肾': 'Kidney', '脾': 'Spleen', '胃': 'Stomach', + '肺': 'Lung', '心': 'Heart', '肠': 'Intestinal', + '带脉': 'Belt Meridian', '经络': 'Meridian', + + # Symptoms & conditions + '酸痛': 'Pain Relief', '疼痛': 'Pain Relief', '止痛': 'Pain Relief', + '感冒': 'Cold', '发烧': 'Fever', '咳嗽': 'Cough', '咽喉': 'Throat', + '过敏': 'Allergy', '鼻炎': 'Rhinitis', '哮喘': 'Asthma', + '湿疹': 'Eczema', '痘痘': 'Acne', '粉刺': 'Acne', + '炎症': 'Anti-Inflammatory', '消炎': 'Anti-Inflammatory', + '便秘': 'Constipation', '腹泻': 'Diarrhea', '消化': 'Digestion', + '失眠': 'Insomnia', '助眠': 'Sleep Aid', '好眠': 'Sleep Well', '安眠': 'Sleep', + '焦虑': 'Anxiety', '抑郁': 'Depression', '情绪': 'Emotional', + '压力': 'Stress', '放松': 'Relaxation', '舒缓': 'Soothing', + '头痛': 'Headache', '偏头痛': 'Migraine', + '水肿': 'Edema', '浮肿': 'Swelling', + '痛经': 'Menstrual Pain', '月经': 'Menstrual', '经期': 'Menstrual', + '更年期': 'Menopause', '荷尔蒙': 'Hormone', + '结节': 'Nodule', '囊肿': 'Cyst', + '灰指甲': 'Nail Fungus', '脚气': 'Athlete\'s Foot', + '白发': 'Gray Hair', '脱发': 'Hair Loss', '生发': 'Hair Growth', + '瘦身': 'Slimming', '减肥': 'Weight Loss', '纤体': 'Body Sculpting', + '紫外线': 'UV', '晒伤': 'Sunburn', '防晒': 'Sun Protection', + '抗衰': 'Anti-Aging', '抗皱': 'Anti-Wrinkle', '美白': 'Whitening', + '补水': 'Hydrating', '保湿': 'Moisturizing', + '排毒': 'Detox', '清洁': 'Cleansing', '净化': 'Purifying', + '驱蚊': 'Mosquito Repellent', '驱虫': 'Insect Repellent', + + # Actions & methods + '护理': 'Care', '调理': 'Therapy', '修复': 'Repair', '养护': 'Nourish', + '按摩': 'Massage', '刮痧': 'Gua Sha', '拔罐': 'Cupping', '艾灸': 'Moxibustion', + '泡脚': 'Foot Soak', '泡澡': 'Bath', '精油浴': 'Oil Bath', + '热敷': 'Hot Compress', '冷敷': 'Cold Compress', '敷面': 'Face Mask', + '喷雾': 'Spray', '滚珠': 'Roll-On', '扩香': 'Diffuser', + '涂抹': 'Topical', '吸嗅': 'Inhalation', + '疏通': 'Unblock', '提升': 'Boost', '增强': 'Enhance', '促进': 'Promote', + '预防': 'Prevention', '改善': 'Improve', + '祛湿': 'Dampness Relief', '驱寒': 'Warming', + '化痰': 'Phlegm Relief', '健脾': 'Spleen Wellness', + '化湿': 'Dampness Clear', '缓解': 'Relief', + + # Beauty + '美容': 'Beauty', '美发': 'Hair Care', '美体': 'Body Care', + '面膜': 'Face Mask', '发膜': 'Hair Mask', '眼霜': 'Eye Cream', + '精华': 'Serum', '乳液': 'Lotion', '洗发': 'Shampoo', + + # General + '配方': 'Blend', '方': 'Blend', '包': 'Blend', + '增强版': 'Enhanced', '高配版': 'Premium', '基础版': 'Basic', + '男士': 'Men\'s', '女士': 'Women\'s', '儿童': 'Children\'s', '宝宝': 'Baby', + '日常': 'Daily', '夜间': 'Night', '早晨': 'Morning', + '呼吸': 'Respiratory', '呼吸系统': 'Respiratory System', + '免疫': 'Immunity', '免疫力': 'Immunity', + '细胞': 'Cellular', '律动': 'Rhythm', +} + +# Longer keys first for greedy matching +_SORTED_KEYS = sorted(_KEYWORDS.keys(), key=len, reverse=True) + + +def auto_translate(name: str) -> str: + """Translate a Chinese recipe name to English using keyword matching.""" + if not name: + return '' + remaining = name.strip() + parts = [] + i = 0 + while i < len(remaining): + matched = False + for key in _SORTED_KEYS: + if remaining[i:i+len(key)] == key: + en = _KEYWORDS[key] + if en not in parts: # avoid duplicates + parts.append(en) + i += len(key) + matched = True + break + if not matched: + # Skip numbers, punctuation, and unrecognized chars + ch = remaining[i] + if ch.isascii() and ch.isalpha(): + # Collect consecutive ASCII chars + j = i + while j < len(remaining) and remaining[j].isascii() and remaining[j].isalpha(): + j += 1 + word = remaining[i:j] + if word not in parts: + parts.append(word) + i = j + else: + i += 1 + + if parts: + result = ' '.join(parts) + # Title case each word but preserve apostrophes (Men's not Men'S) + return ' '.join(w[0].upper() + w[1:] if w else w for w in result.split()) + # Fallback: return original name + return name diff --git a/frontend/cypress/e2e/pr27-features.cy.js b/frontend/cypress/e2e/pr27-features.cy.js new file mode 100644 index 0000000..1b0a22e --- /dev/null +++ b/frontend/cypress/e2e/pr27-features.cy.js @@ -0,0 +1,269 @@ +describe('PR27 Feature Tests', () => { + const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62' + const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` } + const TEST_USERNAME = 'cypress_pr27_user' + + // ------------------------------------------------------------------------- + // API: en_name auto title case on recipe create + // ------------------------------------------------------------------------- + describe('API: en_name auto title case', () => { + let recipeId + + after(() => { + // Cleanup + if (recipeId) { + cy.request({ + method: 'DELETE', + url: `/api/recipes/${recipeId}`, + headers: authHeaders, + failOnStatusCode: false + }) + } + }) + + it('auto title-cases en_name when provided', () => { + cy.request({ + method: 'POST', + url: '/api/recipes', + headers: authHeaders, + body: { + name: 'PR27标题测试', + en_name: 'pain relief blend', + ingredients: [{ oil_name: '薰衣草', drops: 3 }], + tags: [] + } + }).then(res => { + expect(res.status).to.be.oneOf([200, 201]) + recipeId = res.body.id + }) + }) + + it('verifies en_name is title-cased', () => { + cy.request('/api/recipes').then(res => { + const found = res.body.find(r => r.name === 'PR27标题测试') + expect(found).to.exist + expect(found.en_name).to.eq('Pain Relief Blend') + recipeId = found.id + }) + }) + + it('auto translates en_name from Chinese when not provided', () => { + cy.request({ + method: 'POST', + url: '/api/recipes', + headers: authHeaders, + body: { + name: '助眠配方', + ingredients: [{ oil_name: '薰衣草', drops: 5 }], + tags: [] + } + }).then(res => { + expect(res.status).to.be.oneOf([200, 201]) + const autoId = res.body.id + + cy.request('/api/recipes').then(listRes => { + const found = listRes.body.find(r => r.id === autoId) + expect(found).to.exist + // auto_translate('助眠配方') should produce English with "Sleep" and "Blend" + expect(found.en_name).to.be.a('string') + expect(found.en_name.length).to.be.greaterThan(0) + expect(found.en_name).to.include('Sleep') + + // Cleanup + cy.request({ + method: 'DELETE', + url: `/api/recipes/${autoId}`, + headers: authHeaders, + failOnStatusCode: false + }) + }) + }) + }) + }) + + // ------------------------------------------------------------------------- + // API: delete user transfers diary recipes to admin + // ------------------------------------------------------------------------- + describe('API: delete user transfers diary', () => { + let testUserId + let testUserToken + + // Cleanup leftover test user + 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 test user', () => { + cy.request({ + method: 'POST', + url: '/api/users', + headers: authHeaders, + body: { + username: TEST_USERNAME, + display_name: 'PR27 Test User', + role: 'editor' + } + }).then(res => { + expect(res.status).to.be.oneOf([200, 201]) + testUserId = res.body.id || res.body._id + testUserToken = res.body.token + expect(testUserId).to.be.a('number') + }) + }) + + it('adds a diary entry for the test user', () => { + const userAuth = { Authorization: `Bearer ${testUserToken}` } + cy.request({ + method: 'POST', + url: '/api/diary', + headers: userAuth, + body: { + name: 'PR27用户日记', + ingredients: [{ oil: '乳香', drops: 4 }, { oil: '薰衣草', drops: 2 }], + note: '转移测试' + } + }).then(res => { + expect(res.status).to.be.oneOf([200, 201]) + }) + }) + + it('deletes the user and transfers diary to admin', () => { + cy.request({ + method: 'DELETE', + url: `/api/users/${testUserId}`, + headers: authHeaders + }).then(res => { + expect(res.status).to.eq(200) + expect(res.body.ok).to.eq(true) + }) + }) + + it('verifies diary was transferred to admin', () => { + cy.request({ + url: '/api/diary', + headers: authHeaders + }).then(res => { + expect(res.body).to.be.an('array') + // Transferred diary should have user's name appended + const transferred = res.body.find(d => d.name && d.name.includes('PR27用户日记') && d.name.includes('PR27 Test User')) + expect(transferred).to.exist + expect(transferred.note).to.eq('转移测试') + + // Cleanup: delete the transferred diary + if (transferred) { + cy.request({ + method: 'DELETE', + url: `/api/diary/${transferred.id}`, + headers: authHeaders, + failOnStatusCode: false + }) + } + }) + }) + }) + + // ------------------------------------------------------------------------- + // API: rename recipe auto-retranslates en_name + // ------------------------------------------------------------------------- + describe('API: rename recipe retranslates en_name', () => { + let recipeId + + after(() => { + if (recipeId) { + cy.request({ method: 'DELETE', url: `/api/recipes/${recipeId}`, headers: authHeaders, failOnStatusCode: false }) + } + }) + + it('creates recipe then renames it, en_name auto-updates', () => { + cy.request({ + method: 'POST', url: '/api/recipes', headers: authHeaders, + body: { name: '头痛', ingredients: [{ oil_name: '薰衣草', drops: 3 }], tags: [] } + }).then(res => { + recipeId = res.body.id + // Verify initial en_name + cy.request('/api/recipes').then(list => { + const r = list.body.find(x => x.id === recipeId) + expect(r.en_name).to.include('Headache') + }) + // Rename to 肩颈按摩 + cy.request({ + method: 'PUT', url: `/api/recipes/${recipeId}`, headers: authHeaders, + body: { name: '肩颈按摩' } + }).then(() => { + cy.request('/api/recipes').then(list => { + const r = list.body.find(x => x.id === recipeId) + expect(r.en_name).to.include('Neck') + expect(r.en_name).to.include('Massage') + }) + }) + }) + }) + }) + + // ------------------------------------------------------------------------- + // API: delete user skips duplicate diary by ingredient content + // ------------------------------------------------------------------------- + describe('API: delete user skips duplicate diary', () => { + const DUP_USER = 'cypress_pr27_dup' + + it('creates user with duplicate diary, deletes, verifies skip', () => { + // Create user + cy.request({ + method: 'POST', url: '/api/users', headers: authHeaders, + body: { username: DUP_USER, display_name: 'Dup Test', role: 'viewer' } + }).then(res => { + const userId = res.body.id + const userToken = res.body.token + const userAuth = { Authorization: `Bearer ${userToken}` } + + // Get a public recipe's ingredients to create a duplicate + cy.request({ url: '/api/recipes', headers: authHeaders }).then(listRes => { + const pub = listRes.body[0] + const dupIngs = pub.ingredients.map(i => ({ oil: i.oil_name, drops: i.drops })) + + // Add diary with same ingredients as public recipe (different name) + cy.request({ + method: 'POST', url: '/api/diary', headers: userAuth, + body: { name: '我的重复方', ingredients: dupIngs, note: '' } + }).then(() => { + // Delete user + cy.request({ method: 'DELETE', url: `/api/users/${userId}`, headers: authHeaders }).then(delRes => { + expect(delRes.body.ok).to.eq(true) + // Verify duplicate was NOT transferred + cy.request({ url: '/api/diary', headers: authHeaders }).then(diaryRes => { + const transferred = diaryRes.body.find(d => d.name && d.name.includes('我的重复方')) + expect(transferred).to.not.exist + }) + }) + }) + }) + }) + }) + }) + + // ------------------------------------------------------------------------- + // UI: 管理配方 login prompt when not logged in + // ------------------------------------------------------------------------- + describe('UI: RecipeManager login prompt', () => { + it('shows login prompt when not logged in', () => { + // Clear any stored auth + cy.clearLocalStorage() + cy.visit('/#/manage') + cy.contains('登录后可管理配方').should('be.visible') + cy.contains('登录 / 注册').should('be.visible') + }) + }) +}) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f8c387a..c90807f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -88,6 +88,7 @@ const allTabs = [ const visibleTabs = computed(() => allTabs.filter(t => { if (!t.hide) return true if (t.hide === 'admin') return auth.isAdmin + if (t.hide === 'editor') return auth.canEdit return true })) const unreadNotifCount = ref(0) diff --git a/frontend/src/__tests__/pr27Features.test.js b/frontend/src/__tests__/pr27Features.test.js new file mode 100644 index 0000000..c70f15a --- /dev/null +++ b/frontend/src/__tests__/pr27Features.test.js @@ -0,0 +1,172 @@ +import { describe, it, expect } from 'vitest' +import { recipeNameEn } from '../composables/useOilTranslation' + +// --------------------------------------------------------------------------- +// EDITOR_ONLY_TAGS includes '已下架' +// --------------------------------------------------------------------------- +describe('EDITOR_ONLY_TAGS', () => { + it('includes 已审核', async () => { + const { EDITOR_ONLY_TAGS } = await import('../stores/recipes') + expect(EDITOR_ONLY_TAGS).toContain('已审核') + }) + + it('includes 已下架', async () => { + const { EDITOR_ONLY_TAGS } = await import('../stores/recipes') + expect(EDITOR_ONLY_TAGS).toContain('已下架') + }) + + it('is an array with at least 2 entries', async () => { + const { EDITOR_ONLY_TAGS } = await import('../stores/recipes') + expect(Array.isArray(EDITOR_ONLY_TAGS)).toBe(true) + expect(EDITOR_ONLY_TAGS.length).toBeGreaterThanOrEqual(2) + }) +}) + +// --------------------------------------------------------------------------- +// English drop/drops pluralization logic +// --------------------------------------------------------------------------- +describe('drop/drops pluralization', () => { + const pluralize = (n) => (n === 1 ? 'drop' : 'drops') + + it('singular: 1 drop', () => { + expect(pluralize(1)).toBe('drop') + }) + + it('plural: 0 drops', () => { + expect(pluralize(0)).toBe('drops') + }) + + it('plural: 2 drops', () => { + expect(pluralize(2)).toBe('drops') + }) + + it('plural: 5 drops', () => { + expect(pluralize(5)).toBe('drops') + }) +}) + +// --------------------------------------------------------------------------- +// 已下架 tag filtering logic (pure function extraction) +// --------------------------------------------------------------------------- +describe('已下架 tag filtering', () => { + const recipes = [ + { name: 'Active Recipe', tags: ['头疗'] }, + { name: 'Delisted Recipe', tags: ['已下架'] }, + { name: 'No Tags Recipe', tags: [] }, + { name: 'Multi Tag', tags: ['热门', '已下架'] }, + { name: 'Null Tags', tags: null }, + ] + + const filterDelisted = (list) => + list.filter((r) => !r.tags || !r.tags.includes('已下架')) + + it('removes recipes with 已下架 tag', () => { + const result = filterDelisted(recipes) + expect(result.map((r) => r.name)).not.toContain('Delisted Recipe') + expect(result.map((r) => r.name)).not.toContain('Multi Tag') + }) + + it('keeps recipes without 已下架 tag', () => { + const result = filterDelisted(recipes) + expect(result.map((r) => r.name)).toContain('Active Recipe') + expect(result.map((r) => r.name)).toContain('No Tags Recipe') + }) + + it('handles null tags gracefully', () => { + const result = filterDelisted(recipes) + expect(result.map((r) => r.name)).toContain('Null Tags') + }) + + it('returns empty array for all-delisted list', () => { + const all = [ + { name: 'A', tags: ['已下架'] }, + { name: 'B', tags: ['已下架', '其他'] }, + ] + expect(filterDelisted(all)).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// recipeNameEn — front-end keyword translation +// --------------------------------------------------------------------------- +describe('recipeNameEn', () => { + it('translates 酸痛包 → Pain Relief Blend', () => { + expect(recipeNameEn('酸痛包')).toBe('Pain Relief Blend') + }) + + it('translates 助眠配方 → Sleep Aid Blend', () => { + expect(recipeNameEn('助眠配方')).toBe('Sleep Aid Blend') + }) + + it('translates 头痛 → Headache', () => { + expect(recipeNameEn('头痛')).toBe('Headache') + }) + + it('translates 肩颈按摩 → Neck & Shoulder Massage', () => { + expect(recipeNameEn('肩颈按摩')).toBe('Neck & Shoulder Massage') + }) + + it('translates 湿疹舒缓 → Eczema Soothing', () => { + expect(recipeNameEn('湿疹舒缓')).toBe('Eczema Soothing') + }) + + it('translates 淋巴排毒 → Lymph Detox', () => { + expect(recipeNameEn('淋巴排毒')).toBe('Lymph Detox') + }) + + it('translates 灰指甲 → Nail Fungus', () => { + expect(recipeNameEn('灰指甲')).toBe('Nail Fungus') + }) + + it('translates 缓解焦虑 → Relief Anxiety', () => { + expect(recipeNameEn('缓解焦虑')).toBe('Relief Anxiety') + }) + + it('returns original name for unknown text', () => { + expect(recipeNameEn('XYZXYZ')).toBe('XYZXYZ') + }) + + it('returns empty/null for empty/null input', () => { + expect(recipeNameEn('')).toBe('') + expect(recipeNameEn(null)).toBeNull() + }) + + it('does not duplicate keywords', () => { + // 酸痛 maps to Pain Relief; should not appear twice + const result = recipeNameEn('酸痛酸痛') + expect(result).toBe('Pain Relief') + }) +}) + +// --------------------------------------------------------------------------- +// Duplicate oil prevention logic +// --------------------------------------------------------------------------- +describe('duplicate oil prevention', () => { + it('detects duplicate oil in ingredient list', () => { + const ings = [ + { oil: '薰衣草', drops: 3 }, + { oil: '茶树', drops: 2 }, + ] + const newOil = '薰衣草' + const isDup = ings.some(i => i.oil === newOil) + expect(isDup).toBe(true) + }) + + it('allows non-duplicate oil', () => { + const ings = [ + { oil: '薰衣草', drops: 3 }, + { oil: '茶树', drops: 2 }, + ] + const newOil = '乳香' + const isDup = ings.some(i => i.oil === newOil) + expect(isDup).toBe(false) + }) + + it('allows same oil for the same row (editing current)', () => { + const ing = { oil: '薰衣草', drops: 3 } + const ings = [ing, { oil: '茶树', drops: 2 }] + // When selecting for the same row, exclude self + const isDup = ings.some(i => i !== ing && i.oil === '薰衣草') + expect(isDup).toBe(false) + }) +}) diff --git a/frontend/src/components/LoginModal.vue b/frontend/src/components/LoginModal.vue index 17e6878..a40c5bb 100644 --- a/frontend/src/components/LoginModal.vue +++ b/frontend/src/components/LoginModal.vue @@ -1,5 +1,5 @@