diff --git a/docs/README.md b/docs/README.md index 1c5ae40..bbc4942 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ The Cost Sharing application helps groups of people (roommates, friends, project ### User Management - **Google OAuth Authentication**: Users log in with their Google account -- **Placeholder Accounts**: Invite people to groups before they create an account - they'll be activated when they first log in +- **Flexible User Creation**: Users can be added to groups before they log in - accounts are created as needed - **Multi-Group Membership**: Users can belong to multiple groups simultaneously ### Expense Splitting diff --git a/docs/api.yaml b/docs/api.yaml index 6898622..a79d208 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -223,7 +223,7 @@ paths: tags: - Groups summary: Add member to group - description: Adds a member to the group by email. Creates placeholder account if user hasn't logged in yet. When a placeholder user logs in for the first time via OAuth, their account is activated with the email and name from OAuth. Caller must be a member of the group. + description: Adds a member to the group by email. Creates user account if user doesn't exist yet. Caller must be a member of the group. parameters: - $ref: '#/components/parameters/GroupId' requestBody: @@ -253,9 +253,6 @@ paths: properties: member: $ref: '#/components/schemas/User' - isNewUser: - type: boolean - description: True if a placeholder account was created '400': description: Validation error content: @@ -299,7 +296,7 @@ paths: tags: - Groups summary: Remove member from group - description: Removes a member from the group. Users can remove themselves, or any member can remove others. A member cannot be removed if they are the group creator or if they are involved in any expenses (either as paidBy or in splitBetween for any expense in the group). + description: Removes a member from the group. A member can remove themselves, or the group creator can remove any member. A member cannot be removed if they are involved in any expenses (either as paidBy or in splitBetween for any expense in the group). parameters: - $ref: '#/components/parameters/GroupId' - name: userId @@ -325,17 +322,17 @@ paths: error: "Resource not found" message: "User not found" '409': - description: Cannot remove member. Either the member is the group creator, or the member is involved in expenses (either as paidBy or in splitBetween for at least one expense in the group). + description: Cannot remove member. Either the authenticated user is not removing themselves and is not the group creator, or the member is involved in expenses (either as paidBy or in splitBetween for at least one expense in the group). content: application/json: schema: $ref: '#/components/schemas/Error' examples: - creator: - summary: Cannot remove group creator + not_authorized: + summary: Cannot remove member - must be removing yourself or be the group creator value: error: "Conflict" - message: "Cannot remove group creator" + message: "You can only remove yourself, or you must be the group creator to remove other members" involved_in_expenses: summary: Cannot remove member involved in expenses value: diff --git a/docs/sample-data.sql b/docs/sample-data.sql index 16a22fd..82a15b7 100644 --- a/docs/sample-data.sql +++ b/docs/sample-data.sql @@ -9,13 +9,13 @@ PRAGMA foreign_keys = ON; -- USERS -- ============================================================================ -INSERT INTO users (id, email, name, is_placeholder, oauth_provider_id) VALUES -(1, 'alice@school.edu', 'Alice', 0, '123456789012345678901'), -(2, 'bob@school.edu', 'Bob', 1, NULL), -(3, 'charlie@school.edu', 'Charlie', 0, '987654321098765432109'), -(4, 'david@school.edu', 'David', 0, '456789012345678901234'), -(5, 'eve@school.edu', 'Eve', 0, '789012345678901234567'), -(6, 'frank@school.edu', 'Frank', 1, NULL); +INSERT INTO users (id, email, name) VALUES +(1, 'alice@school.edu', 'Alice'), +(2, 'bob@school.edu', 'Bob'), +(3, 'charlie@school.edu', 'Charlie'), +(4, 'david@school.edu', 'David'), +(5, 'eve@school.edu', 'Eve'), +(6, 'frank@school.edu', 'Frank'); -- ============================================================================ -- GROUPS diff --git a/docs/sample-dataset.md b/docs/sample-dataset.md index 2bb066d..77e634d 100644 --- a/docs/sample-dataset.md +++ b/docs/sample-dataset.md @@ -5,36 +5,31 @@ This document provides example data for a collection of users in three different ## Users -In addition to a name, email address (which must be unique), and list of groups, each user has: +Each user has a name, email address (which must be unique), and list of groups. -- **Placeholder Account**: This flag indicates whether the user has never logged in (T = placeholder, F = active user who has logged in). -- **OAuth Provider ID**: This value is provided by Google OAuth to identify the user (it is `NULL` for placeholder accounts who never logged in). - - - -| Name | Email | Placeholder Account | OAuth Provider ID | Group Membership | -|------|-------|---------|-------------------|------------------| -| Alice | alice@school.edu | F | `123456789012345678901` | Group 1 (creator), Group 2 | -| Bob | bob@school.edu | T | NULL | Group 1 | -| Charlie | charlie@school.edu | F | `987654321098765432109` | Group 2 (creator) | -| David | david@school.edu | F | `456789012345678901234` | Group 2 | -| Eve | eve@school.edu | F | `789012345678901234567` | Group 3 (creator) | -| Frank | frank@school.edu | T | NULL | Group 3 | +| Name | Email | Group Membership | +|------|-------|------------------| +| Alice | alice@school.edu | Group 1 (creator), Group 2 | +| Bob | bob@school.edu | Group 1 | +| Charlie | charlie@school.edu | Group 2 (creator) | +| David | david@school.edu | Group 2 | +| Eve | eve@school.edu | Group 3 (creator) | +| Frank | frank@school.edu | Group 3 | --- -## Group 1: Empty Group with Placeholder Member +## Group 1: Empty Group -This group is made up of two users, one of which has never logged in. There are no expenses associated with the group. +This group is made up of two users. There are no expenses associated with the group. - **Name**: "Weekend Trip Planning" - **Description**: "Planning expenses for upcoming weekend getaway" - **Creator**: Alice (User ID: 1) - **Members**: - - Alice (User ID: 1) - Active user, group creator - - Bob (User ID: 2) - Placeholder user (hasn't logged in yet) + - Alice (User ID: 1) - Group creator + - Bob (User ID: 2) ### Expenses @@ -61,7 +56,7 @@ are combined when balances are computed. - **Name**: "Roommates Spring 2025" - **Description**: "Shared expenses for apartment 4B" - **Creator**: Charlie (User ID: 3) -- **Members** (all active, non-placeholder): +- **Members**: - Charlie (User ID: 3) - Group creator - Alice (User ID: 1) - Also a member of Group 1 (cross-group membership) - David (User ID: 4) @@ -122,17 +117,17 @@ are combined when balances are computed. --- -## Group 3: Group with Placeholder Member and Expense +## Group 3: Group with Expense -This group has two members, one of which has never logged in. The creator of the group has already added an expense. +This group has two members. The creator of the group has already added an expense. ### Group Details - **Name**: "Project Team Expenses" - **Description**: "Team project collaboration costs" - **Creator**: Eve (User ID: 5) - **Members**: - - Eve (User ID: 5) - Active user, group creator - - Frank (User ID: 6) - Placeholder user (hasn't logged in yet) + - Eve (User ID: 5) - Group creator + - Frank (User ID: 6) ### Expenses diff --git a/docs/schema-sqlite.sql b/docs/schema-sqlite.sql index 99dfaed..b151d91 100644 --- a/docs/schema-sqlite.sql +++ b/docs/schema-sqlite.sql @@ -1,8 +1,7 @@ -- Cost Sharing API Database Schema --- SQLite3 Schema for Flask Application (Simplified) +-- SQLite3 Schema for Flask Application -- -- This schema implements the data model for the Cost Sharing API. --- Simplified for small-scale class project (handful of users/groups, few hundred expenses). -- -- All monetary amounts are stored as NUMERIC(10,2) for precision. -- Expense dates are stored as DATE (YYYY-MM-DD format). @@ -14,16 +13,12 @@ PRAGMA foreign_keys = ON; -- ============================================================================ -- USERS TABLE -- ============================================================================ --- Stores user accounts. Users can be created via OAuth or as placeholders --- when added to a group before they've logged in. +-- Stores user accounts. Users can be created when added to a group. CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - is_placeholder INTEGER NOT NULL DEFAULT 0, -- 0 = false, 1 = true - -- OAuth provider ID (for Google OAuth user identification) - oauth_provider_id TEXT + name TEXT NOT NULL ); -- ============================================================================ @@ -79,62 +74,3 @@ CREATE TABLE expense_participants ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, PRIMARY KEY (expense_id, user_id) ); - --- ============================================================================ --- NOTES FOR IMPLEMENTATION --- ============================================================================ --- --- 1. ID GENERATION: --- - IDs are auto-generated by SQLite using AUTOINCREMENT --- - Sequential integers: 1, 2, 3, ... --- - No need to generate IDs in application code --- - After INSERT, get the ID: cursor.lastrowid or db.session.commit() then object.id --- - In SQLAlchemy: user = User(...); db.session.add(user); db.session.commit(); user.id is set --- --- 2. DATE HANDLING: --- - SQLite stores DATE as ISO 8601 date strings: "2025-01-10" --- - Expense dates use YYYY-MM-DD format --- - In Python: use date objects or strings in "YYYY-MM-DD" format --- --- 3. BOOLEAN VALUES: --- - Store as INTEGER: 0 = false, 1 = true --- - In Python: use bool() to convert, or store 0/1 directly --- - SQLAlchemy's Boolean type handles conversion automatically --- --- 4. NUMERIC PRECISION: --- - NUMERIC(10,2) stores up to 99,999,999.99 --- - SQLite will enforce precision --- - In Python: use Decimal type for calculations, convert to float for JSON --- --- 5. VALIDATION RULES (enforced in application layer): --- - paidBy must be in splitBetween (expense_participants) --- - All users in splitBetween must be group members --- - paidBy must be a group member --- - Cannot delete group if expenses exist (check expenses table) --- - Cannot remove member if they're in expense_participants or paid_by --- --- 6. TRANSACTIONS: --- - SQLite supports transactions: BEGIN TRANSACTION; ... COMMIT; --- - Use for: creating expense + participants, adding member + creating placeholder user --- - In Flask: use db.session.begin() or @db.transaction decorator --- --- 7. CASCADING DELETES: --- - Deleting a group deletes all expenses and group_members (CASCADE) --- - Deleting an expense deletes all expense_participants (CASCADE) --- - Deleting a user is RESTRICTED if they're involved in expenses or created groups --- --- 8. PERFORMANCE: --- - This simplified schema omits indexes, views, and triggers for simplicity --- - At small scale (handful of users/groups, few hundred expenses), performance is excellent --- - Full table scans are fast enough (< 5ms for typical queries) --- - If needed later, see full_db/ folder for optimized version with indexes --- --- 9. FLASK SETUP: --- - Use Flask-SQLAlchemy for ORM (recommended) --- - Or use sqlite3 directly with connection management --- - Enable foreign keys: conn.execute("PRAGMA foreign_keys = ON") --- --- 10. BACKUP: --- - SQLite database is a single file: cost_sharing.db --- - Easy to backup: just copy the file --- - Can use .dump command: sqlite3 cost_sharing.db .dump > backup.sql diff --git a/docs/usecases.md b/docs/usecases.md index 711be3b..6323ae0 100644 --- a/docs/usecases.md +++ b/docs/usecases.md @@ -31,7 +31,7 @@ This document outlines the complete list of use cases for the Cost Sharing appli 3. User authenticates with Google 4. System receives OAuth callback with authorization code 5. System exchanges code for user information -6. System matches OAuth provider ID to existing user account +6. System matches email to existing user account 7. System generates and returns JWT token 8. User is logged in and can access the application @@ -39,26 +39,7 @@ This document outlines the complete list of use cases for the Cost Sharing appli --- -### UC-AUTH-003: Placeholder User Activation -**Actor**: Placeholder User (invited but never logged in) -**Precondition**: User was added to a group as a placeholder (email/name only, no OAuth) -**Main Flow**: -1. Placeholder user initiates Google OAuth login -2. System receives OAuth callback with authorization code -3. System exchanges code for user information -4. System matches email from OAuth to existing placeholder account -5. System activates placeholder account by: - - Setting `is_placeholder` to false - - Setting `oauth_provider_id` from OAuth - - Updating name if different from placeholder -6. System generates and returns JWT token -7. User is logged in and can access the application - -**Postcondition**: Placeholder account is activated, user is authenticated - ---- - -### UC-AUTH-004: Get Current User Information +### UC-AUTH-003: Get Current User Information **Actor**: Authenticated User **Precondition**: User has valid authentication token **Main Flow**: @@ -132,41 +113,25 @@ This document outlines the complete list of use cases for the Cost Sharing appli --- -### UC-GROUP-005: Add Member to Group (Existing User) +### UC-GROUP-005: Add Member to Group **Actor**: Authenticated User (must be group member) -**Precondition**: User is logged in, is a member of the group, and target user already has an account +**Precondition**: User is logged in and is a member of the group **Main Flow**: 1. User provides email and name of person to add 2. System validates email format 3. System checks if user with email already exists -4. System checks if user is already a member of the group - - If user is already a member: System returns 409 Conflict with error message -5. If not already a member, system adds user to group -6. System returns member information and `isNewUser: false` - -**Postcondition**: Existing user is added as group member - ---- - -### UC-GROUP-006: Add Member to Group (New Placeholder User) -**Actor**: Authenticated User (must be group member) -**Precondition**: User is logged in, is a member of the group, and target user does not have an account -**Main Flow**: -1. User provides email and name of person to add -2. System validates email format -3. System checks if user with email exists -4. If user does not exist, system creates placeholder account: +4. If user does not exist, system creates new user account: - Email and name from input - - `is_placeholder` = true - - `oauth_provider_id` = NULL -5. System adds placeholder user to group -6. System returns member information and `isNewUser: true` +5. System checks if user is already a member of the group + - If user is already a member: System returns 409 Conflict with error message +6. If not already a member, system adds user to group +7. System returns member information -**Postcondition**: Placeholder user account is created and added as group member +**Postcondition**: User (existing or newly created) is added as group member --- -### UC-GROUP-007: Remove Member from Group (Self) +### UC-GROUP-006: Remove Member from Group (Self) **Actor**: Authenticated User (removing themselves) **Precondition**: User is logged in, is a member of the group, is not the creator, and is not involved in any expenses **Main Flow**: @@ -183,7 +148,7 @@ This document outlines the complete list of use cases for the Cost Sharing appli --- -### UC-GROUP-008: Remove Member from Group (Other Member) +### UC-GROUP-007: Remove Member from Group (Other Member) **Actor**: Authenticated User (removing another member) **Precondition**: User is logged in, is a member of the group, target user is not the creator, and target user is not involved in any expenses **Main Flow**: diff --git a/src/cost_sharing/cost_sharing.py b/src/cost_sharing/cost_sharing.py new file mode 100644 index 0000000..ec8c4e8 --- /dev/null +++ b/src/cost_sharing/cost_sharing.py @@ -0,0 +1,44 @@ +"""Application layer for Cost Sharing system.""" + + +class CostSharing: + """Application layer for Cost Sharing system.""" + + def __init__(self, storage): + """ + Initialize the application layer with a storage implementation. + + Args: + storage: CostStorage implementation (e.g., InMemoryCostStorage, DBCostStorage) + """ + self._storage = storage + + def get_user_by_id(self, user_id): + """ + Get user by their ID. + + Args: + user_id: User ID + + Returns: + User object + + Raises: + UserNotFoundError: If user doesn't exist + """ + return self._storage.get_user_by_id(user_id) + + def get_or_create_user(self, email, name): + """ + Get existing user or create new user. + + Args: + email: User's email + name: User's name + + Returns: + User object (existing or newly created) + """ + if self._storage.is_user(email): + return self._storage.get_user_by_email(email) + return self._storage.create_user(email, name) diff --git a/src/cost_sharing/exceptions.py b/src/cost_sharing/exceptions.py index d5dcad0..f5e8925 100644 --- a/src/cost_sharing/exceptions.py +++ b/src/cost_sharing/exceptions.py @@ -5,9 +5,5 @@ class DuplicateEmailError(Exception): """Raised when attempting to create a user with an email that already exists""" -class DuplicateOAuthProviderIdError(Exception): - """Raised when attempting to create a user with an oauth_provider_id that already exists""" - - class UserNotFoundError(Exception): """Raised when a requested user cannot be found""" diff --git a/src/cost_sharing/models.py b/src/cost_sharing/models.py index 1e1b8ca..8b45637 100644 --- a/src/cost_sharing/models.py +++ b/src/cost_sharing/models.py @@ -9,4 +9,3 @@ class User: id: int email: str name: str - oauth_provider_id: str diff --git a/src/cost_sharing/oauth_handler.py b/src/cost_sharing/oauth_handler.py index 68edee5..17371a1 100644 --- a/src/cost_sharing/oauth_handler.py +++ b/src/cost_sharing/oauth_handler.py @@ -99,7 +99,7 @@ def exchange_code_for_user_info(self, oauth_code): # pragma: no cover oauth_code: Authorization code from Google OAuth callback Returns: - Dict with keys: email, name, oauth_provider_id + Dict with keys: email, name Raises: OAuthCodeError: If authorization code is invalid or expired @@ -123,15 +123,14 @@ def exchange_code_for_user_info(self, oauth_code): # pragma: no cover # Extract required fields from verified token # These should always be present in a valid Google ID token - if 'email' not in id_info or 'name' not in id_info or 'sub' not in id_info: + if 'email' not in id_info or 'name' not in id_info: raise OAuthVerificationError( - "Token missing required fields (email, name, or sub)" + "Token missing required fields (email or name)" ) return { "email": id_info['email'], - "name": id_info['name'], - "oauth_provider_id": id_info['sub'] # Google's unique user ID + "name": id_info['name'] } except oauth2_errors.OAuth2Error as e: raise OAuthCodeError(f"Invalid or expired authorization code: {str(e)}") from e diff --git a/tests/storage.py b/src/cost_sharing/storage.py similarity index 55% rename from tests/storage.py rename to src/cost_sharing/storage.py index 9384cb9..a4a84e3 100644 --- a/tests/storage.py +++ b/src/cost_sharing/storage.py @@ -1,7 +1,10 @@ """In-memory storage implementation for testing and mocking""" from cost_sharing.models import User -from cost_sharing.exceptions import DuplicateEmailError, DuplicateOAuthProviderIdError +from cost_sharing.exceptions import ( + DuplicateEmailError, + UserNotFoundError +) class InMemoryCostStorage: @@ -17,56 +20,65 @@ def __init__(self): self._users = {} self._next_id = 1 self._email_index = {} - self._oauth_provider_id_index = {} - def find_user_by_oauth_provider_id(self, oauth_provider_id): + def is_user(self, email): """ - Find user by OAuth provider ID. + Check if a user exists with the given email. Args: - oauth_provider_id: OAuth provider's unique user ID (Google's 'sub') + email: User's email address + + Returns: + bool: True if user exists, False otherwise + """ + return email in self._email_index + + def get_user_by_email(self, email): + """ + Get user by email address. + + Args: + email: User's email address Returns: - User if found, None otherwise + User if found + + Raises: + UserNotFoundError: If user with the given email is not found """ - return self._oauth_provider_id_index.get(oauth_provider_id) + user = self._email_index.get(email) + if user is None: + raise UserNotFoundError(f"User with email '{email}' not found") + return user - def create_user(self, email, name, oauth_provider_id): + def create_user(self, email, name): """ Create a new user. Args: email: User's email address name: User's name - oauth_provider_id: OAuth provider's unique user ID (Google's 'sub') Returns: Newly created User object Raises: DuplicateEmailError: If email already exists - DuplicateOAuthProviderIdError: If oauth_provider_id already exists """ # Check for duplicate email if email in self._email_index: raise DuplicateEmailError() - # Check for duplicate oauth_provider_id - if oauth_provider_id in self._oauth_provider_id_index: - raise DuplicateOAuthProviderIdError() - # Create user with auto-incremented ID user = User( id=self._next_id, email=email, - name=name, - oauth_provider_id=oauth_provider_id + name=name ) # Store in all indices self._users[user.id] = user self._email_index[email] = user - self._oauth_provider_id_index[oauth_provider_id] = user # Increment next ID self._next_id += 1 @@ -81,6 +93,12 @@ def get_user_by_id(self, user_id): user_id: User ID Returns: - User if found, None otherwise + User if found + + Raises: + UserNotFoundError: If user with the given ID is not found """ - return self._users.get(user_id) + user = self._users.get(user_id) + if user is None: + raise UserNotFoundError(f"User with ID {user_id} not found") + return user diff --git a/tests/test_cost_sharing.py b/tests/test_cost_sharing.py new file mode 100644 index 0000000..75840aa --- /dev/null +++ b/tests/test_cost_sharing.py @@ -0,0 +1,54 @@ +"""Tests for CostSharing application layer""" + +import pytest +from cost_sharing.cost_sharing import CostSharing +from cost_sharing.storage import InMemoryCostStorage +from cost_sharing.exceptions import UserNotFoundError + + +@pytest.fixture(name='app') +def create_app_with_user(): + """Fixture for CostSharing with a single existing user""" + storage = InMemoryCostStorage() + storage.create_user(email="test@example.com", name="Test User") + return CostSharing(storage) + + +def test_get_user_by_id_succeeds(app): + """Test get_user_by_id - succeeds when user exists""" + user = app.get_user_by_id(1) + + assert user.id == 1 + assert user.email == "test@example.com" + assert user.name == "Test User" + + +def test_get_user_by_id_raises_exception(app): + """Test get_user_by_id - raises exception when user doesn't exist""" + with pytest.raises(UserNotFoundError): + app.get_user_by_id(999) + + +def test_get_or_create_user_returns_existing(app): + """Test get_or_create_user - returns existing user when email exists""" + user = app.get_or_create_user( + email="test@example.com", + name="Updated Name" + ) + + # Should return existing user, not create new one + assert user.id == 1 + assert user.email == "test@example.com" + assert user.name == "Test User" # Original name, not updated + + +def test_get_or_create_user_returns_new(app): + """Test get_or_create_user - creates and returns new user when email doesn't exist""" + user = app.get_or_create_user( + email="newuser@example.com", + name="New User" + ) + + assert user.email == "newuser@example.com" + assert user.name == "New User" + assert user.id == 2 # Second user created diff --git a/tests/test_storage.py b/tests/test_storage.py index 4c15c42..0748c47 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,9 +1,12 @@ """Tests for InMemoryCostStorage""" import pytest -from storage import InMemoryCostStorage +from cost_sharing.storage import InMemoryCostStorage from cost_sharing.models import User -from cost_sharing.exceptions import DuplicateEmailError, DuplicateOAuthProviderIdError +from cost_sharing.exceptions import ( + DuplicateEmailError, + UserNotFoundError +) @pytest.fixture(name='storage') @@ -16,22 +19,20 @@ def test_create_user(storage): """Test creating a new user""" user = storage.create_user( email="test@example.com", - name="Test User", - oauth_provider_id="oauth-123" + name="Test User" ) assert isinstance(user, User) assert user.id == 1 assert user.email == "test@example.com" assert user.name == "Test User" - assert user.oauth_provider_id == "oauth-123" def test_create_user_auto_increment_id(storage): """Test that user IDs auto-increment""" - user1 = storage.create_user("user1@example.com", "User One", "oauth-1") - user2 = storage.create_user("user2@example.com", "User Two", "oauth-2") - user3 = storage.create_user("user3@example.com", "User Three", "oauth-3") + user1 = storage.create_user("user1@example.com", "User One") + user2 = storage.create_user("user2@example.com", "User Two") + user3 = storage.create_user("user3@example.com", "User Three") assert user1.id == 1 assert user2.id == 2 @@ -40,72 +41,66 @@ def test_create_user_auto_increment_id(storage): def test_create_user_duplicate_email(storage): """Test that creating user with duplicate email raises DuplicateEmailError""" - storage.create_user("test@example.com", "Test User", "oauth-123") + storage.create_user("test@example.com", "Test User") with pytest.raises(DuplicateEmailError): - storage.create_user("test@example.com", "Another User", "oauth-456") + storage.create_user("test@example.com", "Another User") -def test_create_user_duplicate_oauth_provider_id(storage): - """Test duplicate oauth_provider_id raises DuplicateOAuthProviderIdError""" - storage.create_user("user1@example.com", "User One", "oauth-123") +def test_is_user(storage): + """Test is_user returns True for existing users""" + storage.create_user("test@example.com", "Test User") - with pytest.raises(DuplicateOAuthProviderIdError): - storage.create_user("user2@example.com", "User Two", "oauth-123") + assert storage.is_user("test@example.com") is True + assert storage.is_user("nonexistent@example.com") is False -def test_get_user_by_id_existing(storage): - """Test getting user by ID when user exists""" - created_user = storage.create_user("test@example.com", "Test User", "oauth-123") +def test_get_user_by_email(storage): + """Test get_user_by_email retrieves existing user by email""" + created_user = storage.create_user("test@example.com", "Test User") - retrieved_user = storage.get_user_by_id(created_user.id) + retrieved_user = storage.get_user_by_email("test@example.com") - assert retrieved_user is not None assert retrieved_user.id == created_user.id assert retrieved_user.email == created_user.email assert retrieved_user.name == created_user.name - assert retrieved_user.oauth_provider_id == created_user.oauth_provider_id -def test_get_user_by_id_nonexistent(storage): - """Test getting user by ID when user doesn't exist""" - retrieved_user = storage.get_user_by_id(999) +def test_get_user_by_email_nonexistent(storage): + """Test get_user_by_email raises UserNotFoundError for non-existent user""" + with pytest.raises(UserNotFoundError): + storage.get_user_by_email("nonexistent@example.com") - assert retrieved_user is None +def test_get_user_by_id_existing(storage): + """Test getting user by ID when user exists""" + created_user = storage.create_user("test@example.com", "Test User") -def test_find_user_by_oauth_provider_id_existing(storage): - """Test finding user by OAuth provider ID when user exists""" - created_user = storage.create_user("test@example.com", "Test User", "oauth-123") - - found_user = storage.find_user_by_oauth_provider_id("oauth-123") - - assert found_user is not None - assert found_user.id == created_user.id - assert found_user.email == created_user.email - assert found_user.name == created_user.name - assert found_user.oauth_provider_id == created_user.oauth_provider_id + retrieved_user = storage.get_user_by_id(created_user.id) + assert retrieved_user.id == created_user.id + assert retrieved_user.email == created_user.email + assert retrieved_user.name == created_user.name -def test_find_user_by_oauth_provider_id_nonexistent(storage): - """Test finding user by OAuth provider ID when user doesn't exist""" - found_user = storage.find_user_by_oauth_provider_id("nonexistent-oauth-id") - assert found_user is None +def test_get_user_by_id_nonexistent(storage): + """Test getting user by ID when user doesn't exist raises UserNotFoundError""" + with pytest.raises(UserNotFoundError): + storage.get_user_by_id(999) def test_multiple_users_stored_separately(storage): """Test that multiple users are stored and can be retrieved independently""" - user1 = storage.create_user("user1@example.com", "User One", "oauth-1") - user2 = storage.create_user("user2@example.com", "User Two", "oauth-2") - user3 = storage.create_user("user3@example.com", "User Three", "oauth-3") + user1 = storage.create_user("user1@example.com", "User One") + user2 = storage.create_user("user2@example.com", "User Two") + user3 = storage.create_user("user3@example.com", "User Three") # Retrieve by ID assert storage.get_user_by_id(user1.id).email == "user1@example.com" assert storage.get_user_by_id(user2.id).email == "user2@example.com" assert storage.get_user_by_id(user3.id).email == "user3@example.com" - # Retrieve by oauth_provider_id - assert storage.find_user_by_oauth_provider_id("oauth-1").id == user1.id - assert storage.find_user_by_oauth_provider_id("oauth-2").id == user2.id - assert storage.find_user_by_oauth_provider_id("oauth-3").id == user3.id + # Retrieve by email + assert storage.get_user_by_email("user1@example.com").id == user1.id + assert storage.get_user_by_email("user2@example.com").id == user2.id + assert storage.get_user_by_email("user3@example.com").id == user3.id