From cd3efcb52814fa7862890d3f6e16246830ae5cb8 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 30 Jan 2026 16:06:42 -0600 Subject: [PATCH] Implement ProfilePage and profanity filter for display names (F1-006) ProfilePage implementation: - Full profile page with avatar, editable display name, session count - LinkedAccountCard and DisplayNameEditor components - useProfile composable wrapping user store operations - Support for linking/unlinking OAuth providers - Logout and logout-all-devices functionality Profanity service with bypass detection: - Uses better-profanity library for base detection - Enhanced to catch common bypass attempts: - Number suffixes/prefixes (shit123, 69fuck) - Leet-speak substitutions (sh1t, f@ck, $hit) - Separator characters (s.h.i.t, f-u-c-k) - Integrated into PATCH /api/users/me endpoint - 17 unit tests covering all normalization strategies Co-Authored-By: Claude Opus 4.5 --- backend/app/api/users.py | 13 + backend/app/services/profanity_service.py | 218 +++++++ backend/pyproject.toml | 1 + .../unit/services/test_profanity_service.py | 211 +++++++ backend/uv.lock | 11 + .../PHASE_F1_authentication.json | 8 +- .../profile/DisplayNameEditor.spec.ts | 387 ++++++++++++ .../components/profile/DisplayNameEditor.vue | 162 +++++ .../profile/LinkedAccountCard.spec.ts | 264 ++++++++ .../components/profile/LinkedAccountCard.vue | 105 ++++ frontend/src/composables/useProfile.spec.ts | 586 ++++++++++++++++++ frontend/src/composables/useProfile.ts | 277 +++++++++ frontend/src/pages/ProfilePage.spec.ts | 299 +++++++++ frontend/src/pages/ProfilePage.vue | 279 +++++++-- 14 files changed, 2780 insertions(+), 41 deletions(-) create mode 100644 backend/app/services/profanity_service.py create mode 100644 backend/tests/unit/services/test_profanity_service.py create mode 100644 frontend/src/components/profile/DisplayNameEditor.spec.ts create mode 100644 frontend/src/components/profile/DisplayNameEditor.vue create mode 100644 frontend/src/components/profile/LinkedAccountCard.spec.ts create mode 100644 frontend/src/components/profile/LinkedAccountCard.vue create mode 100644 frontend/src/composables/useProfile.spec.ts create mode 100644 frontend/src/composables/useProfile.ts create mode 100644 frontend/src/pages/ProfilePage.spec.ts diff --git a/backend/app/api/users.py b/backend/app/api/users.py index d5a87f9..1171800 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -30,6 +30,7 @@ from app.api.deps import CurrentUser, DeckServiceDep, UserServiceDep from app.schemas.deck import DeckResponse, StarterDeckSelectRequest, StarterStatusResponse from app.schemas.user import UserResponse, UserUpdate from app.services.deck_service import DeckLimitExceededError, StarterAlreadySelectedError +from app.services.profanity_service import validate_display_name from app.services.token_store import token_store from app.services.user_service import AccountLinkingError @@ -77,7 +78,19 @@ async def update_current_user_profile( Returns: Updated user profile. + + Raises: + HTTPException: 400 if display_name contains profanity. """ + # Validate display name for profanity + if update_data.display_name is not None: + is_valid, error = validate_display_name(update_data.display_name) + if not is_valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=error, + ) + updated_user = await user_service.update(user.id, update_data) return UserResponse.model_validate(updated_user) diff --git a/backend/app/services/profanity_service.py b/backend/app/services/profanity_service.py new file mode 100644 index 0000000..019ac56 --- /dev/null +++ b/backend/app/services/profanity_service.py @@ -0,0 +1,218 @@ +"""Profanity filtering service for user-generated content. + +This module provides validation for display names and other user-provided +text to filter inappropriate language. + +Uses the better-profanity library for detection with customizable word lists. +Includes preprocessing to catch common bypass attempts like: +- Numbers attached to words (shit123) +- Leet-speak substitutions (sh1t, f@ck) +- Special characters embedded in words (s.h.i.t) + +Example: + from app.services.profanity_service import validate_display_name + + # In an API endpoint + is_valid, error = validate_display_name("PlayerName") + if not is_valid: + raise HTTPException(400, error) +""" + +import re + +from better_profanity import profanity + +# Initialize profanity filter with default word list +# Can be customized with profanity.add_censor_words([...]) +profanity.load_censor_words() + +# Leet-speak character mappings for normalization +LEET_SUBSTITUTIONS: dict[str, str] = { + "0": "o", + "1": "i", + "3": "e", + "4": "a", + "5": "s", + "7": "t", + "8": "b", + "@": "a", + "$": "s", + "!": "i", + "+": "t", +} + + +class ProfanityValidationError(Exception): + """Error raised when content contains profanity.""" + + pass + + +def _separate_letters_numbers(text: str) -> str: + """Separate letter sequences from number sequences with spaces. + + Catches bypass attempts like "shit123" -> "shit 123". + + Args: + text: The text to process. + + Returns: + Text with spaces between letter and number sequences. + """ + result = re.sub(r"([a-zA-Z])(\d)", r"\1 \2", text) + result = re.sub(r"(\d)([a-zA-Z])", r"\1 \2", result) + return result + + +def _apply_leet_substitutions(text: str) -> str: + """Convert leet-speak characters to their letter equivalents. + + Catches bypass attempts like "sh1t", "f@ck", "$hit". + + Args: + text: The text to process. + + Returns: + Text with leet-speak characters replaced. + """ + result = text.lower() + for leet, letter in LEET_SUBSTITUTIONS.items(): + result = result.replace(leet, letter) + return result + + +def _remove_separators(text: str) -> str: + """Remove separator characters used to break up words. + + Catches bypass attempts like "s.h.i.t", "f-u-c-k". + + Args: + text: The text to process. + + Returns: + Text with separator characters removed. + """ + return re.sub(r"[.\-_]", "", text) + + +def contains_profanity(text: str) -> bool: + """Check if text contains profanity. + + Applies multiple normalization strategies to catch bypass attempts: + 1. Direct check on original text + 2. Separate letters from numbers (shit123 -> shit 123) + 3. Leet-speak substitution (sh1t -> shit) + 4. Separator removal (s.h.i.t -> shit) + + Args: + text: The text to check. + + Returns: + True if profanity is detected, False otherwise. + + Example: + if contains_profanity(username): + reject_username() + """ + # Check original text first + if profanity.contains_profanity(text): + return True + + # Check with letters separated from numbers (shit123 -> shit 123) + separated = _separate_letters_numbers(text) + if profanity.contains_profanity(separated): + return True + + # Check with leet-speak substitutions + leet_normalized = _apply_leet_substitutions(text) + if profanity.contains_profanity(leet_normalized): + return True + + # Check with separators removed (s.h.i.t -> shit) + no_separators = _remove_separators(text) + if profanity.contains_profanity(no_separators): + return True + + # Check combined: leet + separators removed + combined = _apply_leet_substitutions(_remove_separators(text)) + return bool(profanity.contains_profanity(combined)) + + +def validate_display_name(name: str) -> tuple[bool, str | None]: + """Validate a display name for profanity. + + Uses enhanced profanity detection that catches bypass attempts + like leet-speak (sh1t) and number suffixes (shit123). + + Args: + name: The display name to validate. + + Returns: + Tuple of (is_valid, error_message). + If valid, returns (True, None). + If invalid, returns (False, "error message"). + + Example: + is_valid, error = validate_display_name("BadWord123") + if not is_valid: + raise HTTPException(400, error) + """ + if contains_profanity(name): + return False, "Display name contains inappropriate language" + return True, None + + +def validate_text(text: str, field_name: str = "text") -> tuple[bool, str | None]: + """Validate arbitrary text for profanity. + + Generic validation function for any user-provided text field. + Uses enhanced profanity detection that catches bypass attempts. + + Args: + text: The text to validate. + field_name: Name of the field for error messages. + + Returns: + Tuple of (is_valid, error_message). + + Example: + is_valid, error = validate_text(bio, "bio") + """ + if contains_profanity(text): + return False, f"{field_name.title()} contains inappropriate language" + return True, None + + +def censor_text(text: str, censor_char: str = "*") -> str: + """Censor profanity in text by replacing with censor characters. + + Useful for displaying user content that may contain profanity + rather than rejecting it entirely. + + Args: + text: The text to censor. + censor_char: Character to use for censoring (default: *). + + Returns: + Text with profanity replaced by censor characters. + + Example: + safe_text = censor_text("some bad words") + # Returns: "some *** words" + """ + return profanity.censor(text, censor_char) + + +def add_custom_words(words: list[str]) -> None: + """Add custom words to the profanity filter. + + Use this to add game-specific or community-specific terms + that should be blocked. + + Args: + words: List of words to add to the filter. + + Example: + add_custom_words(["customterm", "anotherterm"]) + """ + profanity.add_censor_words(words) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d420a10..0b92b3c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "alembic>=1.18.1", "asyncpg>=0.31.0", "bcrypt>=5.0.0", + "better-profanity>=0.7.0", "email-validator>=2.3.0", "fastapi>=0.128.0", "httpx>=0.28.1", diff --git a/backend/tests/unit/services/test_profanity_service.py b/backend/tests/unit/services/test_profanity_service.py new file mode 100644 index 0000000..7a8fde3 --- /dev/null +++ b/backend/tests/unit/services/test_profanity_service.py @@ -0,0 +1,211 @@ +"""Tests for profanity service. + +Unit tests for the profanity filtering service that validates +user-generated content like display names. +""" + +from app.services.profanity_service import ( + censor_text, + contains_profanity, + validate_display_name, + validate_text, +) + + +class TestContainsProfanity: + """Tests for the contains_profanity function.""" + + def test_clean_text_returns_false(self) -> None: + """ + Test that clean text is not flagged as profanity. + + Normal usernames and display names should pass validation + without being incorrectly flagged. + """ + assert contains_profanity("PlayerOne") is False + assert contains_profanity("CoolGamer123") is False + assert contains_profanity("DragonMaster") is False + + def test_profane_text_returns_true(self) -> None: + """ + Test that profane text is correctly detected. + + Common profanity should be detected to prevent inappropriate + usernames from being created. + """ + # Using a word that's definitely in the default word list + assert contains_profanity("shit") is True + assert contains_profanity("fuck") is True + + def test_mixed_case_profanity_detected(self) -> None: + """ + Test that profanity is detected regardless of case. + + Users may try to bypass filters using mixed case, which + should still be caught. + """ + assert contains_profanity("SHIT") is True + assert contains_profanity("ShIt") is True + + def test_empty_string_is_clean(self) -> None: + """ + Test that empty strings are not flagged. + + Empty strings don't contain profanity by definition. + """ + assert contains_profanity("") is False + + def test_profanity_with_number_suffix_detected(self) -> None: + """ + Test that profanity followed by numbers is detected. + + Users commonly try to bypass filters by appending numbers + to bad words (e.g., "shit123"). The filter should catch these. + """ + assert contains_profanity("shit123") is True + assert contains_profanity("fuck2024") is True + assert contains_profanity("ass99") is True + + def test_profanity_with_number_prefix_detected(self) -> None: + """ + Test that profanity preceded by numbers is detected. + + Numbers before bad words should also be caught. + """ + assert contains_profanity("123shit") is True + assert contains_profanity("69fuck") is True + + def test_leet_speak_profanity_detected(self) -> None: + """ + Test that leet-speak substitutions are detected. + + Common character substitutions (@ for a, 1 for i, etc.) + should be normalized and caught by the filter. + """ + assert contains_profanity("sh1t") is True + assert contains_profanity("f@ck") is True + assert contains_profanity("a$$") is True + assert contains_profanity("$hit") is True + + def test_profanity_with_separators_detected(self) -> None: + """ + Test that profanity broken up with separators is detected. + + Users may insert dots, dashes, or underscores between letters + to break up bad words. The filter should remove these and check. + """ + assert contains_profanity("s.h.i.t") is True + assert contains_profanity("f-u-c-k") is True + assert contains_profanity("s_h_i_t") is True + + def test_clean_words_with_numbers_not_flagged(self) -> None: + """ + Test that clean words with numbers are not incorrectly flagged. + + Normal gaming-style names with numbers should pass validation. + """ + assert contains_profanity("Player123") is False + assert contains_profanity("Gamer2024") is False + assert contains_profanity("Dragon99") is False + assert contains_profanity("FireMage42") is False + + +class TestValidateDisplayName: + """Tests for the validate_display_name function.""" + + def test_valid_name_returns_success(self) -> None: + """ + Test that valid display names pass validation. + + Normal display names should return (True, None) indicating + they are valid with no error message. + """ + is_valid, error = validate_display_name("GoodPlayer") + assert is_valid is True + assert error is None + + def test_profane_name_returns_error(self) -> None: + """ + Test that profane display names return an error. + + Display names containing profanity should return (False, error) + with a user-friendly error message. + """ + is_valid, error = validate_display_name("shit123") + assert is_valid is False + assert error is not None + assert "inappropriate" in error.lower() + + def test_error_message_is_user_friendly(self) -> None: + """ + Test that error messages are suitable for display to users. + + The error message should be professional and not contain + the actual profanity. + """ + is_valid, error = validate_display_name("fuckface") + assert is_valid is False + assert error == "Display name contains inappropriate language" + + +class TestValidateText: + """Tests for the generic validate_text function.""" + + def test_valid_text_passes(self) -> None: + """ + Test that clean text passes validation. + + Generic text validation should work the same as display name + validation for clean content. + """ + is_valid, error = validate_text("This is a clean message", "message") + assert is_valid is True + assert error is None + + def test_custom_field_name_in_error(self) -> None: + """ + Test that the field name appears in error messages. + + The error message should reference the specific field that + failed validation for clarity. + """ + is_valid, error = validate_text("shit", "bio") + assert is_valid is False + assert "Bio" in error # Capitalized field name + + +class TestCensorText: + """Tests for the censor_text function.""" + + def test_profanity_is_censored(self) -> None: + """ + Test that profanity is replaced with asterisks. + + The censor function should replace bad words while keeping + the rest of the text intact. + """ + result = censor_text("what the shit") + assert "shit" not in result + assert "****" in result + + def test_clean_text_unchanged(self) -> None: + """ + Test that clean text is returned unchanged. + + Text without profanity should pass through the censor + function without modification. + """ + original = "This is a clean message" + result = censor_text(original) + assert result == original + + def test_custom_censor_char(self) -> None: + """ + Test that custom censor characters are used. + + Users should be able to specify alternative censor + characters for different display contexts. + """ + result = censor_text("what the shit", censor_char="#") + assert "#" in result + assert "*" not in result diff --git a/backend/uv.lock b/backend/uv.lock index 5df9de4..789f04d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -166,6 +166,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] +[[package]] +name = "better-profanity" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/52966c1c883819f0b48540312a4a7f63b5c49f4e5b1a10838ae7f06ebb3c/better_profanity-0.7.0.tar.gz", hash = "sha256:8a6fdc8606d7471e7b5f6801917eca98ec211098262e82f62da4f5de3a73145b", size = 29977, upload-time = "2020-11-02T10:49:57.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/dd/0b074d89e903cc771721cde2c4bf3d8c9d114b5bd791af5c62bcf5fb9459/better_profanity-0.7.0-py3-none-any.whl", hash = "sha256:bd4c529ea6aa2db1aaa50524be1ed14d0fe5c664f1fd88c8bc388c7e9f9f00e8", size = 46104, upload-time = "2020-11-02T10:49:56.066Z" }, +] + [[package]] name = "bidict" version = "0.23.1" @@ -614,6 +623,7 @@ dependencies = [ { name = "alembic" }, { name = "asyncpg" }, { name = "bcrypt" }, + { name = "better-profanity" }, { name = "email-validator" }, { name = "fastapi" }, { name = "httpx" }, @@ -649,6 +659,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.18.1" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "bcrypt", specifier = ">=5.0.0" }, + { name = "better-profanity", specifier = ">=0.7.0" }, { name = "email-validator", specifier = ">=2.3.0" }, { name = "fastapi", specifier = ">=0.128.0" }, { name = "httpx", specifier = ">=0.28.1" }, diff --git a/frontend/project_plans/PHASE_F1_authentication.json b/frontend/project_plans/PHASE_F1_authentication.json index 7db7ed7..aa5eda5 100644 --- a/frontend/project_plans/PHASE_F1_authentication.json +++ b/frontend/project_plans/PHASE_F1_authentication.json @@ -4,9 +4,9 @@ "name": "Authentication Flow", "version": "1.0.0", "created": "2026-01-30", - "lastUpdated": "2026-01-30T15:00:00Z", + "lastUpdated": "2026-01-30T16:00:00Z", "totalTasks": 10, - "completedTasks": 5, + "completedTasks": 6, "status": "in_progress", "description": "Complete OAuth authentication flow including login, callback handling, starter deck selection, profile management, and app initialization." }, @@ -192,8 +192,8 @@ "description": "User profile management with linked accounts", "category": "pages", "priority": 6, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["F1-003"], "files": [ {"path": "src/pages/ProfilePage.vue", "status": "modify"}, diff --git a/frontend/src/components/profile/DisplayNameEditor.spec.ts b/frontend/src/components/profile/DisplayNameEditor.spec.ts new file mode 100644 index 0000000..9db609d --- /dev/null +++ b/frontend/src/components/profile/DisplayNameEditor.spec.ts @@ -0,0 +1,387 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' + +import DisplayNameEditor from './DisplayNameEditor.vue' + +describe('DisplayNameEditor', () => { + describe('view mode', () => { + it('displays the current name', () => { + /** + * Test that the display name is shown in view mode. + * + * By default, the component shows the current name + * with an edit button, not an input field. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + expect(wrapper.text()).toContain('Test User') + }) + + it('shows edit button', () => { + /** + * Test that an edit button is visible in view mode. + * + * Users need a way to enter edit mode to change their name. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + const editButton = wrapper.find('button') + expect(editButton.exists()).toBe(true) + }) + + it('enters edit mode when edit button is clicked', async () => { + /** + * Test entering edit mode. + * + * Clicking the edit button should switch from viewing + * to editing the display name. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + await wrapper.find('button').trigger('click') + + // Should now show an input field + expect(wrapper.find('input').exists()).toBe(true) + }) + }) + + describe('edit mode', () => { + it('shows input with current value', async () => { + /** + * Test that input is pre-filled with current name. + * + * When entering edit mode, the input should contain + * the current display name for easy editing. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + // Enter edit mode + await wrapper.find('button').trigger('click') + + const input = wrapper.find('input') + expect((input.element as HTMLInputElement).value).toBe('Test User') + }) + + it('shows save and cancel buttons', async () => { + /** + * Test that save and cancel buttons are visible. + * + * Users need clear actions to either save changes + * or cancel and revert to the original value. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + await wrapper.find('button').trigger('click') + + const buttons = wrapper.findAll('button') + const buttonTexts = buttons.map(b => b.text()) + + expect(buttonTexts.some(t => t.includes('Save'))).toBe(true) + expect(buttonTexts.some(t => t.includes('Cancel'))).toBe(true) + }) + + it('shows character count', async () => { + /** + * Test that character count is displayed. + * + * Users should know how many characters they've used + * relative to the maximum allowed. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + await wrapper.find('button').trigger('click') + + expect(wrapper.text()).toContain('/32') + }) + + it('cancels and reverts on cancel button click', async () => { + /** + * Test cancel functionality. + * + * Clicking cancel should exit edit mode and discard + * any changes made to the input. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + // Enter edit mode + await wrapper.find('button').trigger('click') + + // Change the value + await wrapper.find('input').setValue('New Name') + + // Click cancel + const cancelButton = wrapper.findAll('button').find(b => b.text().includes('Cancel')) + await cancelButton!.trigger('click') + + // Should be back in view mode with original name + expect(wrapper.find('input').exists()).toBe(false) + expect(wrapper.text()).toContain('Test User') + }) + + it('cancels on Escape key', async () => { + /** + * Test keyboard shortcut for cancel. + * + * Pressing Escape should cancel editing as a + * common keyboard shortcut. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + await wrapper.find('button').trigger('click') + await wrapper.find('input').trigger('keydown', { key: 'Escape' }) + + expect(wrapper.find('input').exists()).toBe(false) + }) + }) + + describe('validation', () => { + it('shows error for empty name', async () => { + /** + * Test validation of empty display name. + * + * Empty or whitespace-only names should be rejected + * with a clear error message. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + await wrapper.find('button').trigger('click') + await wrapper.find('input').setValue('') + + // Click save + const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save')) + await saveButton!.trigger('click') + + expect(wrapper.text()).toContain('cannot be empty') + }) + + it('shows error for name shorter than 2 characters', async () => { + /** + * Test minimum length validation. + * + * Very short names are likely typos and should be rejected. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + await wrapper.find('button').trigger('click') + await wrapper.find('input').setValue('A') + + const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save')) + await saveButton!.trigger('click') + + expect(wrapper.text()).toContain('at least 2 characters') + }) + + it('shows error for name longer than 32 characters', async () => { + /** + * Test maximum length validation. + * + * Names that are too long should be rejected to prevent + * UI issues and database constraints. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + await wrapper.find('button').trigger('click') + await wrapper.find('input').setValue('A'.repeat(33)) + + const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save')) + await saveButton!.trigger('click') + + expect(wrapper.text()).toContain('cannot exceed 32') + }) + + it('does not emit save with validation errors', async () => { + /** + * Test that invalid input does not trigger save. + * + * The component should validate locally before emitting + * the save event to avoid unnecessary API calls. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + await wrapper.find('button').trigger('click') + await wrapper.find('input').setValue('') + + const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save')) + await saveButton!.trigger('click') + + expect(wrapper.emitted('save')).toBeFalsy() + }) + }) + + describe('saving', () => { + it('emits save event with trimmed value', async () => { + /** + * Test save event emission. + * + * When the user saves, the component should emit the + * trimmed display name for the parent to handle. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + await wrapper.find('button').trigger('click') + await wrapper.find('input').setValue(' New Name ') + + const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save')) + await saveButton!.trigger('click') + + expect(wrapper.emitted('save')).toBeTruthy() + expect(wrapper.emitted('save')![0]).toEqual(['New Name']) + }) + + it('saves on Enter key', async () => { + /** + * Test keyboard shortcut for save. + * + * Pressing Enter should save the changes as a + * common keyboard shortcut. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + await wrapper.find('button').trigger('click') + await wrapper.find('input').setValue('New Name') + await wrapper.find('input').trigger('keydown', { key: 'Enter' }) + + expect(wrapper.emitted('save')).toBeTruthy() + }) + + it('disables buttons while saving', async () => { + /** + * Test button states during save. + * + * While saving, buttons should be disabled to prevent + * duplicate submissions. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: true, + }, + }) + + await wrapper.find('button').trigger('click') + + const buttons = wrapper.findAll('button') + buttons.forEach(button => { + if (button.text().includes('Save') || button.text().includes('Cancel')) { + expect(button.attributes('disabled')).toBeDefined() + } + }) + }) + + it('shows "Saving..." text while saving', async () => { + /** + * Test loading state display. + * + * Users should see feedback that their save is in progress. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: true, + }, + }) + + await wrapper.find('button').trigger('click') + + expect(wrapper.text()).toContain('Saving') + }) + + it('exits edit mode when modelValue matches saved value', async () => { + /** + * Test automatic exit from edit mode on success. + * + * When the parent updates the modelValue to match the + * saved value, edit mode should close automatically. + */ + const wrapper = mount(DisplayNameEditor, { + props: { + modelValue: 'Test User', + isSaving: false, + }, + }) + + // Enter edit mode and save + await wrapper.find('button').trigger('click') + await wrapper.find('input').setValue('New Name') + + const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save')) + await saveButton!.trigger('click') + + // Simulate parent updating the value + await wrapper.setProps({ modelValue: 'New Name' }) + + // Should exit edit mode + expect(wrapper.find('input').exists()).toBe(false) + }) + }) +}) diff --git a/frontend/src/components/profile/DisplayNameEditor.vue b/frontend/src/components/profile/DisplayNameEditor.vue new file mode 100644 index 0000000..0e51544 --- /dev/null +++ b/frontend/src/components/profile/DisplayNameEditor.vue @@ -0,0 +1,162 @@ + + + diff --git a/frontend/src/components/profile/LinkedAccountCard.spec.ts b/frontend/src/components/profile/LinkedAccountCard.spec.ts new file mode 100644 index 0000000..3972972 --- /dev/null +++ b/frontend/src/components/profile/LinkedAccountCard.spec.ts @@ -0,0 +1,264 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' + +import LinkedAccountCard from './LinkedAccountCard.vue' +import type { LinkedAccount } from '@/composables/useProfile' + +describe('LinkedAccountCard', () => { + const createAccount = (overrides: Partial = {}): LinkedAccount => ({ + provider: 'google', + providerUserId: 'g123', + email: 'test@gmail.com', + linkedAt: '2026-01-15T10:30:00Z', + ...overrides, + }) + + describe('rendering', () => { + it('displays provider name', () => { + /** + * Test that the provider name is displayed. + * + * Users should be able to identify which OAuth provider + * each linked account belongs to. + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount({ provider: 'google' }), + isOnlyAccount: false, + isUnlinking: false, + }, + }) + + expect(wrapper.text()).toContain('Google') + }) + + it('displays email when available', () => { + /** + * Test that the email is displayed for linked accounts. + * + * The email helps users identify which specific account + * is linked (e.g., if they have multiple Google accounts). + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount({ email: 'user@example.com' }), + isOnlyAccount: false, + isUnlinking: false, + }, + }) + + expect(wrapper.text()).toContain('user@example.com') + }) + + it('displays fallback text when email is null', () => { + /** + * Test fallback display when no email is available. + * + * Some providers may not provide email (e.g., Discord). + * We should show a reasonable fallback. + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount({ provider: 'discord', email: null }), + isOnlyAccount: false, + isUnlinking: false, + }, + }) + + expect(wrapper.text()).toContain('Discord Account') + }) + + it('displays linked date formatted', () => { + /** + * Test that the linked date is displayed in a readable format. + * + * Users should know when they linked each account for security. + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount({ linkedAt: '2026-01-15T10:30:00Z' }), + isOnlyAccount: false, + isUnlinking: false, + }, + }) + + // Should contain "Linked" and some date text + expect(wrapper.text()).toContain('Linked') + expect(wrapper.text()).toMatch(/Jan|15|2026/) + }) + + it('shows correct icon for Google', () => { + /** + * Test Google provider icon. + * + * Visual differentiation helps users quickly identify providers. + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount({ provider: 'google' }), + isOnlyAccount: false, + isUnlinking: false, + }, + }) + + expect(wrapper.text()).toContain('🔵') + }) + + it('shows correct icon for Discord', () => { + /** + * Test Discord provider icon. + * + * Visual differentiation helps users quickly identify providers. + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount({ provider: 'discord' }), + isOnlyAccount: false, + isUnlinking: false, + }, + }) + + expect(wrapper.text()).toContain('🟣') + }) + }) + + describe('unlink button', () => { + it('is enabled when not the only account', () => { + /** + * Test that unlink is enabled with multiple accounts. + * + * Users should be able to unlink accounts as long as + * they have at least one other linked account. + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount(), + isOnlyAccount: false, + isUnlinking: false, + }, + }) + + const button = wrapper.find('button') + expect(button.attributes('disabled')).toBeUndefined() + }) + + it('is disabled when only account', () => { + /** + * Test that unlink is disabled for the only account. + * + * Users cannot unlink their only OAuth provider because + * they would be locked out of their account. + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount(), + isOnlyAccount: true, + isUnlinking: false, + }, + }) + + const button = wrapper.find('button') + expect(button.attributes('disabled')).toBeDefined() + }) + + it('is disabled while unlinking', () => { + /** + * Test that unlink is disabled during operation. + * + * Prevent double-clicks and show feedback while + * the unlink operation is in progress. + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount(), + isOnlyAccount: false, + isUnlinking: true, + }, + }) + + const button = wrapper.find('button') + expect(button.attributes('disabled')).toBeDefined() + expect(button.text()).toContain('Unlinking') + }) + + it('shows tooltip explaining why unlink is disabled', () => { + /** + * Test tooltip for disabled unlink button. + * + * Users should understand why they can't unlink + * their only account. + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount(), + isOnlyAccount: true, + isUnlinking: false, + }, + }) + + const button = wrapper.find('button') + expect(button.attributes('title')).toContain('Cannot unlink') + }) + + it('emits unlink event with provider when clicked', async () => { + /** + * Test unlink event emission. + * + * When the user clicks unlink, the component should emit + * an event with the provider name for the parent to handle. + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount({ provider: 'discord' }), + isOnlyAccount: false, + isUnlinking: false, + }, + }) + + await wrapper.find('button').trigger('click') + + expect(wrapper.emitted('unlink')).toBeTruthy() + expect(wrapper.emitted('unlink')![0]).toEqual(['discord']) + }) + + it('does not emit when only account', async () => { + /** + * Test that unlink is not emitted for only account. + * + * Even if the button is somehow clicked, the event + * should not be emitted. + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount(), + isOnlyAccount: true, + isUnlinking: false, + }, + }) + + await wrapper.find('button').trigger('click') + + expect(wrapper.emitted('unlink')).toBeFalsy() + }) + + it('does not emit while unlinking', async () => { + /** + * Test that unlink is not emitted while in progress. + * + * Prevent duplicate requests if button is clicked + * while an operation is already in progress. + */ + const wrapper = mount(LinkedAccountCard, { + props: { + account: createAccount(), + isOnlyAccount: false, + isUnlinking: true, + }, + }) + + await wrapper.find('button').trigger('click') + + expect(wrapper.emitted('unlink')).toBeFalsy() + }) + }) +}) diff --git a/frontend/src/components/profile/LinkedAccountCard.vue b/frontend/src/components/profile/LinkedAccountCard.vue new file mode 100644 index 0000000..7724c06 --- /dev/null +++ b/frontend/src/components/profile/LinkedAccountCard.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/src/composables/useProfile.spec.ts b/frontend/src/composables/useProfile.spec.ts new file mode 100644 index 0000000..e1fea2c --- /dev/null +++ b/frontend/src/composables/useProfile.spec.ts @@ -0,0 +1,586 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +import { useUserStore } from '@/stores/user' + +// Mock the API client +vi.mock('@/api/client', () => ({ + apiClient: { + get: vi.fn(), + delete: vi.fn(), + }, +})) + +// Mock the config +vi.mock('@/config', () => ({ + config: { + apiBaseUrl: 'http://localhost:8000', + wsUrl: 'http://localhost:8000', + oauthRedirectUri: 'http://localhost:5173/auth/callback', + isDev: true, + isProd: false, + }, +})) + +import { apiClient } from '@/api/client' +import { ApiError } from '@/api/types' +import { useProfile } from './useProfile' + +describe('useProfile', () => { + let mockLocation: { href: string } + + beforeEach(() => { + setActivePinia(createPinia()) + + // Mock window.location for initiateLink + mockLocation = { + href: 'http://localhost:5173/profile', + } + Object.defineProperty(window, 'location', { + value: { ...mockLocation, origin: 'http://localhost:5173' }, + writable: true, + configurable: true, + }) + + // Reset mocks + vi.mocked(apiClient.get).mockReset() + vi.mocked(apiClient.delete).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('initial state', () => { + it('starts with null profile', () => { + /** + * Test that useProfile starts with no profile data. + * + * Before fetchProfile is called, the profile should be null + * indicating no data has been loaded yet. + */ + const { profile, displayName, avatarUrl, linkedAccounts } = useProfile() + + expect(profile.value).toBeNull() + expect(displayName.value).toBe('Unknown') + expect(avatarUrl.value).toBeUndefined() + expect(linkedAccounts.value).toEqual([]) + }) + + it('starts with isLoading as false', () => { + /** + * Test initial loading state. + * + * The composable should not be in a loading state until + * an async operation is initiated. + */ + const { isLoading } = useProfile() + + expect(isLoading.value).toBe(false) + }) + + it('starts with no error', () => { + /** + * Test initial error state. + * + * The composable should start with no errors. + */ + const { error } = useProfile() + + expect(error.value).toBeNull() + }) + + it('starts with null session count', () => { + /** + * Test initial session count state. + * + * Session count should be null until fetchSessionCount is called. + */ + const { sessionCount } = useProfile() + + expect(sessionCount.value).toBeNull() + }) + }) + + describe('fetchProfile', () => { + it('delegates to user store fetchProfile', async () => { + /** + * Test that fetchProfile calls through to the user store. + * + * The composable wraps the store, so fetchProfile should + * delegate to the store's implementation. + */ + const userStore = useUserStore() + const fetchSpy = vi.spyOn(userStore, 'fetchProfile').mockResolvedValue(true) + + const { fetchProfile } = useProfile() + const result = await fetchProfile() + + expect(fetchSpy).toHaveBeenCalled() + expect(result).toBe(true) + }) + + it('clears local error before fetching', async () => { + /** + * Test error clearing on fetch. + * + * Starting a new fetch should clear any previous errors + * so users don't see stale error messages. + */ + const userStore = useUserStore() + vi.spyOn(userStore, 'fetchProfile').mockResolvedValue(true) + + const { fetchProfile, error } = useProfile() + await fetchProfile() + + expect(error.value).toBeNull() + }) + }) + + describe('updateDisplayName', () => { + it('successfully updates display name', async () => { + /** + * Test successful display name update. + * + * When the user updates their display name, the composable + * should call the store and return success. + */ + const userStore = useUserStore() + vi.spyOn(userStore, 'updateDisplayName').mockResolvedValue(true) + + const { updateDisplayName } = useProfile() + const result = await updateDisplayName('New Name') + + expect(result.success).toBe(true) + expect(result.error).toBeUndefined() + }) + + it('trims the display name before updating', async () => { + /** + * Test display name trimming. + * + * Leading/trailing whitespace should be removed before + * sending to the server. + */ + const userStore = useUserStore() + const updateSpy = vi.spyOn(userStore, 'updateDisplayName').mockResolvedValue(true) + + const { updateDisplayName } = useProfile() + await updateDisplayName(' New Name ') + + expect(updateSpy).toHaveBeenCalledWith('New Name') + }) + + it('returns error for empty display name', async () => { + /** + * Test validation of empty display name. + * + * Empty or whitespace-only names should be rejected + * without making an API call. + */ + const userStore = useUserStore() + const updateSpy = vi.spyOn(userStore, 'updateDisplayName') + + const { updateDisplayName } = useProfile() + const result = await updateDisplayName(' ') + + expect(result.success).toBe(false) + expect(result.error).toBe('Display name cannot be empty') + expect(updateSpy).not.toHaveBeenCalled() + }) + + it('returns error when store update fails', async () => { + /** + * Test error handling when update fails. + * + * If the store returns false (failed to update), + * the composable should return an error result. + */ + const userStore = useUserStore() + vi.spyOn(userStore, 'updateDisplayName').mockResolvedValue(false) + userStore.error = 'Server error' + + const { updateDisplayName } = useProfile() + const result = await updateDisplayName('New Name') + + expect(result.success).toBe(false) + expect(result.error).toBe('Server error') + }) + + it('sets isUpdatingName while updating', async () => { + /** + * Test loading state during display name update. + * + * Components should be able to show a loading indicator + * while the update is in progress. + */ + const userStore = useUserStore() + let resolveUpdate: () => void + vi.spyOn(userStore, 'updateDisplayName').mockImplementation( + () => new Promise((resolve) => { resolveUpdate = () => resolve(true) }) + ) + + const { updateDisplayName, isUpdatingName } = useProfile() + + const promise = updateDisplayName('New Name') + + expect(isUpdatingName.value).toBe(true) + + resolveUpdate!() + await promise + + expect(isUpdatingName.value).toBe(false) + }) + }) + + describe('initiateLink', () => { + it('redirects to Google link URL', () => { + /** + * Test Google account linking initiation. + * + * When initiating Google linking, the browser should be redirected + * to the backend's Google link endpoint with a redirect_uri. + */ + const { initiateLink } = useProfile() + + initiateLink('google') + + expect(window.location.href).toContain('/api/auth/link/google') + expect(window.location.href).toContain('redirect_uri=') + }) + + it('redirects to Discord link URL', () => { + /** + * Test Discord account linking initiation. + * + * When initiating Discord linking, the browser should be redirected + * to the backend's Discord link endpoint with a redirect_uri. + */ + const { initiateLink } = useProfile() + + initiateLink('discord') + + expect(window.location.href).toContain('/api/auth/link/discord') + expect(window.location.href).toContain('redirect_uri=') + }) + + it('clears any existing error', () => { + /** + * Test error clearing on link initiation. + * + * Starting a new link flow should clear any previous errors. + */ + const { initiateLink, error } = useProfile() + + // Manually set an error state (simulate previous failure) + initiateLink('google') + + // Error should be cleared (we can't easily test this without internal access) + // The implementation clears localError before redirect + expect(error.value).toBeNull() + }) + }) + + describe('unlinkAccount', () => { + beforeEach(() => { + // Set up user store with linked accounts + const userStore = useUserStore() + userStore.profile = { + id: 'user-1', + displayName: 'Test User', + avatarUrl: null, + hasStarterDeck: true, + createdAt: '2026-01-01T00:00:00Z', + linkedAccounts: [ + { provider: 'google', providerUserId: 'g123', email: 'test@gmail.com', linkedAt: '2026-01-01T00:00:00Z' }, + { provider: 'discord', providerUserId: 'd456', email: null, linkedAt: '2026-01-02T00:00:00Z' }, + ], + } + }) + + it('successfully unlinks an account', async () => { + /** + * Test successful account unlinking. + * + * When unlinking a provider, the composable should call the + * API and return success. + */ + vi.mocked(apiClient.delete).mockResolvedValue(undefined) + const userStore = useUserStore() + vi.spyOn(userStore, 'fetchProfile').mockResolvedValue(true) + + const { unlinkAccount } = useProfile() + const result = await unlinkAccount('discord') + + expect(result.success).toBe(true) + expect(apiClient.delete).toHaveBeenCalledWith('/api/users/me/link/discord') + }) + + it('refreshes profile after unlinking', async () => { + /** + * Test profile refresh after unlink. + * + * After successfully unlinking, the profile should be refreshed + * to get the updated linked accounts list. + */ + vi.mocked(apiClient.delete).mockResolvedValue(undefined) + const userStore = useUserStore() + const fetchSpy = vi.spyOn(userStore, 'fetchProfile').mockResolvedValue(true) + + const { unlinkAccount } = useProfile() + await unlinkAccount('discord') + + expect(fetchSpy).toHaveBeenCalled() + }) + + it('prevents unlinking the only linked account', async () => { + /** + * Test protection against unlinking last account. + * + * Users cannot unlink their only OAuth provider because + * they would be locked out of their account. + */ + const userStore = useUserStore() + // Only one linked account + userStore.profile!.linkedAccounts = [ + { provider: 'google', providerUserId: 'g123', email: 'test@gmail.com', linkedAt: '2026-01-01T00:00:00Z' }, + ] + + const { unlinkAccount } = useProfile() + const result = await unlinkAccount('google') + + expect(result.success).toBe(false) + expect(result.error).toContain('Cannot unlink the only linked account') + expect(apiClient.delete).not.toHaveBeenCalled() + }) + + it('returns error on API failure', async () => { + /** + * Test error handling for unlink API failure. + * + * If the API call fails, the composable should return + * an appropriate error message. + */ + vi.mocked(apiClient.delete).mockRejectedValue( + new ApiError(400, 'Bad Request', 'Cannot unlink primary provider') + ) + + const { unlinkAccount } = useProfile() + const result = await unlinkAccount('discord') + + expect(result.success).toBe(false) + expect(result.error).toBe('Cannot unlink primary provider') + }) + + it('sets isLoadingUnlink while unlinking', async () => { + /** + * Test loading state during unlink. + * + * Components should be able to show a loading indicator + * while the unlink operation is in progress. + */ + let resolveDelete: () => void + vi.mocked(apiClient.delete).mockImplementation( + () => new Promise((resolve) => { resolveDelete = () => resolve(undefined) }) + ) + const userStore = useUserStore() + vi.spyOn(userStore, 'fetchProfile').mockResolvedValue(true) + + const { unlinkAccount, isLoadingUnlink } = useProfile() + + const promise = unlinkAccount('discord') + + expect(isLoadingUnlink.value).toBe(true) + + resolveDelete!() + await promise + + expect(isLoadingUnlink.value).toBe(false) + }) + }) + + describe('fetchSessionCount', () => { + it('successfully fetches session count', async () => { + /** + * Test successful session count fetch. + * + * The composable should fetch the session count from the API + * and store it for display. + */ + vi.mocked(apiClient.get).mockResolvedValue({ activeSessionCount: 3 }) + + const { fetchSessionCount, sessionCount } = useProfile() + const result = await fetchSessionCount() + + expect(result).toBe(3) + expect(sessionCount.value).toBe(3) + }) + + it('returns null on API failure', async () => { + /** + * Test graceful handling of session count fetch failure. + * + * Session count is not critical, so failures should be + * handled gracefully without throwing. + */ + vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error')) + + const { fetchSessionCount, sessionCount } = useProfile() + const result = await fetchSessionCount() + + expect(result).toBeNull() + expect(sessionCount.value).toBeNull() + }) + + it('sets isLoadingSession while fetching', async () => { + /** + * Test loading state during session count fetch. + * + * Components may want to show a loading indicator + * while fetching session information. + */ + let resolveGet: (value: unknown) => void + vi.mocked(apiClient.get).mockImplementation( + () => new Promise((resolve) => { resolveGet = resolve }) + ) + + const profile = useProfile() + + const promise = profile.fetchSessionCount() + + expect(profile.isLoadingSession.value).toBe(true) + + resolveGet!({ activeSessionCount: 2 }) + await promise + + expect(profile.isLoadingSession.value).toBe(false) + }) + }) + + describe('isProviderLinked', () => { + it('returns true for linked providers', () => { + /** + * Test linked provider detection. + * + * The composable should correctly identify which providers + * are currently linked to the user's account. + */ + const userStore = useUserStore() + userStore.profile = { + id: 'user-1', + displayName: 'Test User', + avatarUrl: null, + hasStarterDeck: true, + createdAt: '2026-01-01T00:00:00Z', + linkedAccounts: [ + { provider: 'google', providerUserId: 'g123', email: 'test@gmail.com', linkedAt: '2026-01-01T00:00:00Z' }, + ], + } + + const { isProviderLinked } = useProfile() + + expect(isProviderLinked('google')).toBe(true) + expect(isProviderLinked('discord')).toBe(false) + }) + + it('returns false when no accounts are linked', () => { + /** + * Test linked provider detection with no accounts. + * + * When no profile is loaded or no accounts are linked, + * both providers should return false. + */ + const { isProviderLinked } = useProfile() + + expect(isProviderLinked('google')).toBe(false) + expect(isProviderLinked('discord')).toBe(false) + }) + }) + + describe('getLinkedAccount', () => { + it('returns linked account for provider', () => { + /** + * Test getting linked account details. + * + * The composable should return the full account details + * for a linked provider. + */ + const userStore = useUserStore() + userStore.profile = { + id: 'user-1', + displayName: 'Test User', + avatarUrl: null, + hasStarterDeck: true, + createdAt: '2026-01-01T00:00:00Z', + linkedAccounts: [ + { provider: 'google', providerUserId: 'g123', email: 'test@gmail.com', linkedAt: '2026-01-01T00:00:00Z' }, + ], + } + + const { getLinkedAccount } = useProfile() + + const account = getLinkedAccount('google') + expect(account).toBeDefined() + expect(account?.email).toBe('test@gmail.com') + }) + + it('returns undefined for unlinked provider', () => { + /** + * Test getting unlinked provider details. + * + * When a provider is not linked, getLinkedAccount should + * return undefined. + */ + const userStore = useUserStore() + userStore.profile = { + id: 'user-1', + displayName: 'Test User', + avatarUrl: null, + hasStarterDeck: true, + createdAt: '2026-01-01T00:00:00Z', + linkedAccounts: [], + } + + const { getLinkedAccount } = useProfile() + + expect(getLinkedAccount('discord')).toBeUndefined() + }) + }) + + describe('clearError', () => { + it('clears the local error state', () => { + /** + * Test error clearing. + * + * After displaying an error, components may want to clear it + * (e.g., when user dismisses or tries again). + */ + const { clearError, error } = useProfile() + + // Error starts as null + expect(error.value).toBeNull() + + // Clear should not throw even if no error + clearError() + + expect(error.value).toBeNull() + }) + }) + + describe('computed isLoading', () => { + it('reflects combined loading states', () => { + /** + * Test combined loading state. + * + * The isLoading computed should be true if any + * operation is in progress. + */ + const userStore = useUserStore() + userStore.isLoading = true + + const { isLoading } = useProfile() + + expect(isLoading.value).toBe(true) + }) + }) +}) diff --git a/frontend/src/composables/useProfile.ts b/frontend/src/composables/useProfile.ts new file mode 100644 index 0000000..b94a8b9 --- /dev/null +++ b/frontend/src/composables/useProfile.ts @@ -0,0 +1,277 @@ +/** + * Profile management composable. + * + * Provides a higher-level API for profile operations including: + * - Profile fetching and updates + * - Linked account management (link/unlink) + * - Session count tracking + * - Loading and error state management + * + * Wraps the user store with additional functionality and cleaner error handling. + */ +import { ref, computed, readonly } from 'vue' + +import { useUserStore } from '@/stores/user' +import { apiClient } from '@/api/client' +import { ApiError } from '@/api/types' +import { config } from '@/config' + +/** OAuth provider types */ +export type OAuthProvider = 'google' | 'discord' + +/** Linked account information from the backend */ +export interface LinkedAccount { + provider: OAuthProvider + providerUserId: string + email: string | null + linkedAt: string +} + +/** Session information from the backend */ +export interface SessionInfo { + activeSessionCount: number +} + +/** Result of link/unlink operations */ +export interface LinkOperationResult { + success: boolean + error?: string +} + +/** Result of profile update operations */ +export interface UpdateProfileResult { + success: boolean + error?: string +} + +/** + * Profile management composable. + * + * Provides methods for viewing and updating user profile, managing linked + * OAuth accounts, and viewing session information. + * + * @example + * ```vue + * + * ``` + */ +export function useProfile() { + const userStore = useUserStore() + + // Local state for operations not in store + const sessionCount = ref(null) + const isLoadingLink = ref(false) + const isLoadingUnlink = ref(false) + const isLoadingSession = ref(false) + const isUpdatingName = ref(false) + const localError = ref(null) + + // Computed from store + const profile = computed(() => userStore.profile) + const displayName = computed(() => userStore.displayName) + const avatarUrl = computed(() => userStore.avatarUrl) + const linkedAccounts = computed(() => userStore.linkedAccounts) + const isLoading = computed( + () => + userStore.isLoading || + isLoadingLink.value || + isLoadingUnlink.value || + isLoadingSession.value || + isUpdatingName.value + ) + const error = computed(() => localError.value || userStore.error) + + /** + * Fetch the user profile from the API. + * + * Updates the user store with fresh profile data including linked accounts. + * + * @returns Whether fetch succeeded + */ + async function fetchProfile(): Promise { + localError.value = null + return userStore.fetchProfile() + } + + /** + * Update the user's display name. + * + * @param newName - New display name (must be non-empty) + * @returns Result indicating success/failure + */ + async function updateDisplayName(newName: string): Promise { + if (!newName.trim()) { + return { success: false, error: 'Display name cannot be empty' } + } + + isUpdatingName.value = true + localError.value = null + + try { + const success = await userStore.updateDisplayName(newName.trim()) + if (!success) { + return { success: false, error: userStore.error || 'Failed to update display name' } + } + return { success: true } + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Failed to update display name' + localError.value = errorMessage + return { success: false, error: errorMessage } + } finally { + isUpdatingName.value = false + } + } + + /** + * Initiate linking an OAuth provider. + * + * Redirects the user to the backend OAuth link endpoint. After OAuth, + * the user is redirected back to /auth/link/callback with success/error. + * + * @param provider - OAuth provider to link ('google' or 'discord') + */ + function initiateLink(provider: OAuthProvider): void { + localError.value = null + const redirectUri = encodeURIComponent(`${window.location.origin}/auth/link/callback`) + window.location.href = `${config.apiBaseUrl}/api/auth/link/${provider}?redirect_uri=${redirectUri}` + } + + /** + * Unlink an OAuth provider from the account. + * + * Cannot unlink the last remaining provider (would lock user out). + * + * @param provider - OAuth provider to unlink + * @returns Result indicating success/failure + */ + async function unlinkAccount(provider: OAuthProvider): Promise { + // Prevent unlinking last provider + if (linkedAccounts.value.length <= 1) { + return { + success: false, + error: 'Cannot unlink the only linked account. Link another provider first.', + } + } + + isLoadingUnlink.value = true + localError.value = null + + try { + await apiClient.delete(`/api/users/me/link/${provider}`) + + // Refresh profile to get updated linked accounts + await userStore.fetchProfile() + + return { success: true } + } catch (e) { + let errorMessage = 'Failed to unlink account' + + if (e instanceof ApiError) { + if (e.status === 400 || e.status === 409) { + errorMessage = e.detail || 'Cannot unlink this account' + } else { + errorMessage = e.detail || e.message + } + } else if (e instanceof Error) { + errorMessage = e.message + } + + localError.value = errorMessage + return { success: false, error: errorMessage } + } finally { + isLoadingUnlink.value = false + } + } + + /** + * Fetch the number of active sessions. + * + * @returns Number of active sessions or null on error + */ + async function fetchSessionCount(): Promise { + isLoadingSession.value = true + localError.value = null + + try { + const response = await apiClient.get('/api/users/me/sessions') + sessionCount.value = response.activeSessionCount + return response.activeSessionCount + } catch (e) { + // Session count is not critical - just log and return null + console.warn('Failed to fetch session count:', e) + return null + } finally { + isLoadingSession.value = false + } + } + + /** + * Check if a provider is currently linked. + * + * @param provider - OAuth provider to check + * @returns Whether the provider is linked + */ + function isProviderLinked(provider: OAuthProvider): boolean { + return linkedAccounts.value.some((acc) => acc.provider === provider) + } + + /** + * Get the linked account for a provider. + * + * @param provider - OAuth provider + * @returns Linked account or undefined + */ + function getLinkedAccount(provider: OAuthProvider): LinkedAccount | undefined { + return linkedAccounts.value.find((acc) => acc.provider === provider) as + | LinkedAccount + | undefined + } + + /** + * Clear any error state. + */ + function clearError(): void { + localError.value = null + } + + return { + // State (readonly to prevent external mutation) + profile, + displayName, + avatarUrl, + linkedAccounts, + sessionCount: readonly(sessionCount), + isLoading, + isUpdatingName: readonly(isUpdatingName), + isLoadingLink: readonly(isLoadingLink), + isLoadingUnlink: readonly(isLoadingUnlink), + isLoadingSession: readonly(isLoadingSession), + error, + + // Actions + fetchProfile, + updateDisplayName, + initiateLink, + unlinkAccount, + fetchSessionCount, + isProviderLinked, + getLinkedAccount, + clearError, + } +} + +export type UseProfile = ReturnType diff --git a/frontend/src/pages/ProfilePage.spec.ts b/frontend/src/pages/ProfilePage.spec.ts new file mode 100644 index 0000000..55eb40e --- /dev/null +++ b/frontend/src/pages/ProfilePage.spec.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' + +import ProfilePage from './ProfilePage.vue' +import { useUserStore } from '@/stores/user' + +// Mock vue-router +const mockRouterPush = vi.fn() +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: mockRouterPush, + }), + useRoute: () => ({ + path: '/profile', + name: 'Profile', + query: {}, + params: {}, + }), +})) + +// Mock useAuth composable +const mockLogout = vi.fn() +const mockLogoutAll = vi.fn() +vi.mock('@/composables/useAuth', () => ({ + useAuth: () => ({ + logout: mockLogout, + logoutAll: mockLogoutAll, + isLoading: { value: false }, + }), +})) + +// Mock API client +vi.mock('@/api/client', () => ({ + apiClient: { + get: vi.fn().mockResolvedValue({ activeSessionCount: 2 }), + delete: vi.fn().mockResolvedValue(undefined), + }, +})) + +// Mock config +vi.mock('@/config', () => ({ + config: { + apiBaseUrl: 'http://localhost:8000', + wsUrl: 'http://localhost:8000', + oauthRedirectUri: 'http://localhost:5173/auth/callback', + isDev: true, + isProd: false, + }, +})) + +// Mock fetch for auth store +global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), +}) + +// Mock window.location +const mockLocation = { + href: '', + origin: 'http://localhost:5173', +} +Object.defineProperty(window, 'location', { + value: mockLocation, + writable: true, + configurable: true, +}) + +// Mock window.confirm +global.confirm = vi.fn() + +describe('ProfilePage', () => { + function createProfileData() { + return { + id: 'user-1', + displayName: 'Test User', + avatarUrl: null, + hasStarterDeck: true, + createdAt: '2026-01-01T00:00:00Z', + linkedAccounts: [ + { + provider: 'google' as const, + providerUserId: 'g123', + email: 'test@gmail.com', + linkedAt: '2026-01-01T00:00:00Z', + }, + ], + } + } + + beforeEach(() => { + setActivePinia(createPinia()) + mockRouterPush.mockReset() + mockLogout.mockReset() + mockLogoutAll.mockReset() + mockLocation.href = '' + vi.mocked(global.confirm).mockReturnValue(false) + + // Set up user store with profile + const userStore = useUserStore() + userStore.profile = createProfileData() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('shows loading state while fetching profile', () => { + /** + * Test loading state display. + * + * While the profile is being fetched, users should see + * a loading indicator instead of an empty page. + */ + const userStore = useUserStore() + userStore.profile = null + userStore.isLoading = true + + const wrapper = mount(ProfilePage) + + expect(wrapper.find('.animate-spin').exists()).toBe(true) + }) + + it('shows profile data when loaded', async () => { + /** + * Test profile data display. + * + * Once loaded, the profile page should show the user's + * display name and other relevant information. + */ + const wrapper = mount(ProfilePage) + + await flushPromises() + + expect(wrapper.text()).toContain('Test User') + expect(wrapper.text()).toContain('Profile') + }) + + it('shows avatar initial when no avatar URL', async () => { + /** + * Test avatar fallback display. + * + * When users don't have an avatar image, we show their + * name initial as a fallback. + */ + const wrapper = mount(ProfilePage) + + await flushPromises() + + expect(wrapper.text()).toContain('T') // First letter of "Test User" + }) + + it('shows member since date', async () => { + /** + * Test member date display. + * + * Users should see when they joined the platform. + */ + const wrapper = mount(ProfilePage) + + await flushPromises() + + expect(wrapper.text()).toContain('Member since') + }) + }) + + describe('linked accounts', () => { + it('displays linked accounts list', async () => { + /** + * Test linked accounts display. + * + * Users should see all their linked OAuth providers + * with relevant details. + */ + const wrapper = mount(ProfilePage) + + await flushPromises() + + expect(wrapper.text()).toContain('Linked Accounts') + expect(wrapper.text()).toContain('Google') + }) + + it('shows link button for unlinked providers', async () => { + /** + * Test link additional provider button. + * + * If a provider isn't linked, users should see an option + * to link it. + */ + const wrapper = mount(ProfilePage) + + await flushPromises() + + // Google is linked, so Discord should have a link button + expect(wrapper.text()).toContain('Link Discord') + }) + + it('hides link buttons when all providers linked', async () => { + /** + * Test hiding link buttons when all linked. + * + * If both Google and Discord are linked, no additional + * link buttons should appear. + */ + const userStore = useUserStore() + userStore.profile!.linkedAccounts = [ + { provider: 'google', providerUserId: 'g123', email: 'test@gmail.com', linkedAt: '2026-01-01T00:00:00Z' }, + { provider: 'discord', providerUserId: 'd456', email: null, linkedAt: '2026-01-02T00:00:00Z' }, + ] + + const wrapper = mount(ProfilePage) + + await flushPromises() + + expect(wrapper.text()).not.toContain('Link Google') + expect(wrapper.text()).not.toContain('Link Discord') + }) + }) + + describe('logout', () => { + it('shows logout buttons in Account Actions section', async () => { + /** + * Test that logout buttons are present. + * + * The profile page should show both a regular logout button + * and a logout all devices button. + */ + const wrapper = mount(ProfilePage) + + await flushPromises() + + // Check the section exists and contains the expected elements + expect(wrapper.text()).toContain('Account Actions') + expect(wrapper.text()).toContain('Logout') + expect(wrapper.text()).toContain('Logout All Devices') + }) + + it('has logout action handlers defined', async () => { + /** + * Test that logout handlers are available. + * + * The page should have click handlers that call the + * auth composable's logout methods. + */ + const wrapper = mount(ProfilePage) + + await flushPromises() + + // The logout buttons should exist and be clickable + const buttons = wrapper.findAll('button') + expect(buttons.length).toBeGreaterThan(0) + + // Verify the handlers exist by checking the component renders properly + expect(wrapper.html()).toContain('bg-red-600') // Logout button styling + expect(wrapper.html()).toContain('bg-red-900') // Logout all button styling + }) + }) + + describe('session info', () => { + it('displays session count section', async () => { + /** + * Test session info section display. + * + * Users should see information about their active sessions. + */ + const wrapper = mount(ProfilePage) + + await flushPromises() + + expect(wrapper.text()).toContain('Sessions') + }) + }) + + describe('error handling', () => { + it('shows error state when profile fetch fails', async () => { + /** + * Test error display on profile fetch failure. + * + * If the profile cannot be loaded, users should see + * an error message explaining the problem. + */ + const userStore = useUserStore() + userStore.profile = null + + // Mock fetchProfile to fail and set error + vi.spyOn(userStore, 'fetchProfile').mockImplementation(async () => { + userStore.error = 'Failed to load profile' + return false + }) + + const wrapper = mount(ProfilePage) + + await flushPromises() + + expect(wrapper.text()).toContain('Failed to load profile') + }) + }) +}) diff --git a/frontend/src/pages/ProfilePage.vue b/frontend/src/pages/ProfilePage.vue index ca707f8..168960b 100644 --- a/frontend/src/pages/ProfilePage.vue +++ b/frontend/src/pages/ProfilePage.vue @@ -2,57 +2,262 @@ /** * User profile page. * - * Displays user info, linked accounts, and logout option. + * Displays user info including avatar, editable display name, linked OAuth + * accounts, and logout options. Users can link additional OAuth providers + * and unlink existing ones (except the last one). */ -import { useAuthStore } from '@/stores/auth' +import { onMounted, computed } from 'vue' -const auth = useAuthStore() +import { useProfile } from '@/composables/useProfile' +import { useAuth } from '@/composables/useAuth' +import { useUiStore } from '@/stores/ui' +import LinkedAccountCard from '@/components/profile/LinkedAccountCard.vue' +import DisplayNameEditor from '@/components/profile/DisplayNameEditor.vue' +import type { OAuthProvider } from '@/composables/useProfile' -function logout() { - auth.logout() +const ui = useUiStore() +const { + profile, + displayName, + avatarUrl, + linkedAccounts, + sessionCount, + isLoading, + isUpdatingName, + isLoadingUnlink, + error, + fetchProfile, + updateDisplayName, + initiateLink, + unlinkAccount, + fetchSessionCount, + isProviderLinked, +} = useProfile() + +const { logout, logoutAll, isLoading: isLoggingOut } = useAuth() + +/** Avatar display - either image or initial */ +const avatarInitial = computed(() => { + return displayName.value?.charAt(0).toUpperCase() || '?' +}) + +/** Check if there's only one linked account */ +const hasOnlyOneAccount = computed(() => linkedAccounts.value.length <= 1) + +/** Available providers that can be linked */ +const availableProviders = computed(() => { + const allProviders: OAuthProvider[] = ['google', 'discord'] + return allProviders.filter((p) => !isProviderLinked(p)) +}) + +// Fetch profile data on mount +onMounted(async () => { + await fetchProfile() + await fetchSessionCount() +}) + +/** Handle display name save */ +async function handleDisplayNameSave(newName: string): Promise { + const result = await updateDisplayName(newName) + if (result.success) { + ui.showSuccess('Display name updated') + } else { + ui.showError(result.error || 'Failed to update display name') + } +} + +/** Handle unlinking an account */ +async function handleUnlink(provider: OAuthProvider): Promise { + const result = await unlinkAccount(provider) + if (result.success) { + ui.showSuccess(`${provider} account unlinked`) + } else { + ui.showError(result.error || 'Failed to unlink account') + } +} + +/** Handle logout (current session) */ +async function handleLogout(): Promise { + await logout() +} + +/** Handle logout all (all devices) */ +async function handleLogoutAll(): Promise { + const confirmed = window.confirm( + 'This will log you out from all devices. Continue?' + ) + if (confirmed) { + await logoutAll() + } }