diff --git a/backend/tests/api/test_boards.py b/backend/tests/api/test_boards.py new file mode 100644 index 0000000..ddfee0e --- /dev/null +++ b/backend/tests/api/test_boards.py @@ -0,0 +1,558 @@ +"""Integration tests for board API endpoints.""" + +import pytest +from fastapi import status +from fastapi.testclient import TestClient + + +@pytest.fixture +def authenticated_client(client: TestClient, test_user_data: dict) -> tuple[TestClient, dict]: + """ + Create authenticated client with token. + + Returns: + Tuple of (client, auth_headers) + """ + # Register and login + client.post("/api/v1/auth/register", json=test_user_data) + login_response = client.post("/api/v1/auth/login", json=test_user_data) + + token = login_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + return client, headers + + +class TestCreateBoardEndpoint: + """Test POST /boards endpoint.""" + + def test_create_board_success(self, authenticated_client: tuple[TestClient, dict]): + """Test successful board creation.""" + client, headers = authenticated_client + + board_data = {"title": "My First Board", "description": "Test description"} + + response = client.post("/api/v1/boards", json=board_data, headers=headers) + + assert response.status_code == status.HTTP_201_CREATED + + data = response.json() + assert "id" in data + assert data["title"] == "My First Board" + assert data["description"] == "Test description" + assert "viewport_state" in data + assert data["viewport_state"]["zoom"] == 1.0 + assert data["is_deleted"] is False + + def test_create_board_minimal(self, authenticated_client: tuple[TestClient, dict]): + """Test creating board with only title.""" + client, headers = authenticated_client + + board_data = {"title": "Minimal Board"} + + response = client.post("/api/v1/boards", json=board_data, headers=headers) + + assert response.status_code == status.HTTP_201_CREATED + + data = response.json() + assert data["title"] == "Minimal Board" + assert data["description"] is None + + def test_create_board_empty_title(self, authenticated_client: tuple[TestClient, dict]): + """Test that empty title is rejected.""" + client, headers = authenticated_client + + board_data = {"title": ""} + + response = client.post("/api/v1/boards", json=board_data, headers=headers) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_create_board_missing_title(self, authenticated_client: tuple[TestClient, dict]): + """Test that missing title is rejected.""" + client, headers = authenticated_client + + board_data = {"description": "No title"} + + response = client.post("/api/v1/boards", json=board_data, headers=headers) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_create_board_unauthenticated(self, client: TestClient): + """Test that unauthenticated users can't create boards.""" + board_data = {"title": "Unauthorized Board"} + + response = client.post("/api/v1/boards", json=board_data) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestListBoardsEndpoint: + """Test GET /boards endpoint.""" + + def test_list_boards_empty(self, authenticated_client: tuple[TestClient, dict]): + """Test listing boards when user has none.""" + client, headers = authenticated_client + + response = client.get("/api/v1/boards", headers=headers) + + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert data["boards"] == [] + assert data["total"] == 0 + assert data["limit"] == 50 + assert data["offset"] == 0 + + def test_list_boards_multiple(self, authenticated_client: tuple[TestClient, dict]): + """Test listing multiple boards.""" + client, headers = authenticated_client + + # Create 3 boards + for i in range(3): + client.post( + "/api/v1/boards", json={"title": f"Board {i}"}, headers=headers + ) + + response = client.get("/api/v1/boards", headers=headers) + + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert len(data["boards"]) == 3 + assert data["total"] == 3 + + def test_list_boards_pagination(self, authenticated_client: tuple[TestClient, dict]): + """Test board pagination.""" + client, headers = authenticated_client + + # Create 5 boards + for i in range(5): + client.post( + "/api/v1/boards", json={"title": f"Board {i}"}, headers=headers + ) + + # Get first page + response1 = client.get("/api/v1/boards?limit=2&offset=0", headers=headers) + data1 = response1.json() + + assert len(data1["boards"]) == 2 + assert data1["total"] == 5 + assert data1["limit"] == 2 + assert data1["offset"] == 0 + + # Get second page + response2 = client.get("/api/v1/boards?limit=2&offset=2", headers=headers) + data2 = response2.json() + + assert len(data2["boards"]) == 2 + assert data2["total"] == 5 + + def test_list_boards_unauthenticated(self, client: TestClient): + """Test that unauthenticated users can't list boards.""" + response = client.get("/api/v1/boards") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestGetBoardEndpoint: + """Test GET /boards/{board_id} endpoint.""" + + def test_get_board_success(self, authenticated_client: tuple[TestClient, dict]): + """Test getting existing board.""" + client, headers = authenticated_client + + # Create board + create_response = client.post( + "/api/v1/boards", json={"title": "Test Board"}, headers=headers + ) + board_id = create_response.json()["id"] + + # Get board + response = client.get(f"/api/v1/boards/{board_id}", headers=headers) + + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert data["id"] == board_id + assert data["title"] == "Test Board" + + def test_get_board_not_found(self, authenticated_client: tuple[TestClient, dict]): + """Test getting nonexistent board.""" + client, headers = authenticated_client + + fake_id = "00000000-0000-0000-0000-000000000000" + + response = client.get(f"/api/v1/boards/{fake_id}", headers=headers) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_get_board_unauthenticated(self, client: TestClient): + """Test that unauthenticated users can't get boards.""" + fake_id = "00000000-0000-0000-0000-000000000000" + + response = client.get(f"/api/v1/boards/{fake_id}") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestUpdateBoardEndpoint: + """Test PATCH /boards/{board_id} endpoint.""" + + def test_update_board_title(self, authenticated_client: tuple[TestClient, dict]): + """Test updating board title.""" + client, headers = authenticated_client + + # Create board + create_response = client.post( + "/api/v1/boards", json={"title": "Original Title"}, headers=headers + ) + board_id = create_response.json()["id"] + + # Update title + update_data = {"title": "Updated Title"} + response = client.patch(f"/api/v1/boards/{board_id}", json=update_data, headers=headers) + + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert data["title"] == "Updated Title" + + def test_update_board_description(self, authenticated_client: tuple[TestClient, dict]): + """Test updating board description.""" + client, headers = authenticated_client + + # Create board + create_response = client.post( + "/api/v1/boards", json={"title": "Test Board"}, headers=headers + ) + board_id = create_response.json()["id"] + + # Update description + update_data = {"description": "New description"} + response = client.patch(f"/api/v1/boards/{board_id}", json=update_data, headers=headers) + + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert data["description"] == "New description" + + def test_update_board_viewport(self, authenticated_client: tuple[TestClient, dict]): + """Test updating viewport state.""" + client, headers = authenticated_client + + # Create board + create_response = client.post( + "/api/v1/boards", json={"title": "Test Board"}, headers=headers + ) + board_id = create_response.json()["id"] + + # Update viewport + update_data = {"viewport_state": {"x": 100, "y": 200, "zoom": 1.5, "rotation": 45}} + response = client.patch(f"/api/v1/boards/{board_id}", json=update_data, headers=headers) + + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert data["viewport_state"]["x"] == 100 + assert data["viewport_state"]["y"] == 200 + assert data["viewport_state"]["zoom"] == 1.5 + assert data["viewport_state"]["rotation"] == 45 + + def test_update_board_invalid_viewport(self, authenticated_client: tuple[TestClient, dict]): + """Test that invalid viewport values are rejected.""" + client, headers = authenticated_client + + # Create board + create_response = client.post( + "/api/v1/boards", json={"title": "Test Board"}, headers=headers + ) + board_id = create_response.json()["id"] + + # Try invalid zoom (out of range) + update_data = {"viewport_state": {"x": 0, "y": 0, "zoom": 10.0, "rotation": 0}} + response = client.patch(f"/api/v1/boards/{board_id}", json=update_data, headers=headers) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_update_board_not_found(self, authenticated_client: tuple[TestClient, dict]): + """Test updating nonexistent board.""" + client, headers = authenticated_client + + fake_id = "00000000-0000-0000-0000-000000000000" + update_data = {"title": "Updated"} + + response = client.patch(f"/api/v1/boards/{fake_id}", json=update_data, headers=headers) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestDeleteBoardEndpoint: + """Test DELETE /boards/{board_id} endpoint.""" + + def test_delete_board_success(self, authenticated_client: tuple[TestClient, dict]): + """Test successfully deleting a board.""" + client, headers = authenticated_client + + # Create board + create_response = client.post( + "/api/v1/boards", json={"title": "Test Board"}, headers=headers + ) + board_id = create_response.json()["id"] + + # Delete board + response = client.delete(f"/api/v1/boards/{board_id}", headers=headers) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify board is gone from listings + list_response = client.get("/api/v1/boards", headers=headers) + boards = list_response.json()["boards"] + assert not any(b["id"] == board_id for b in boards) + + def test_delete_board_not_found(self, authenticated_client: tuple[TestClient, dict]): + """Test deleting nonexistent board.""" + client, headers = authenticated_client + + fake_id = "00000000-0000-0000-0000-000000000000" + + response = client.delete(f"/api/v1/boards/{fake_id}", headers=headers) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_board_unauthenticated(self, client: TestClient): + """Test that unauthenticated users can't delete boards.""" + fake_id = "00000000-0000-0000-0000-000000000000" + + response = client.delete(f"/api/v1/boards/{fake_id}") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestBoardOwnershipIsolation: + """Test that users can only access their own boards.""" + + def test_users_cannot_see_each_others_boards(self, client: TestClient): + """Test that users only see their own boards in listings.""" + # Create user1 and boards + user1_data = {"email": "user1@example.com", "password": "Password123"} + client.post("/api/v1/auth/register", json=user1_data) + login1 = client.post("/api/v1/auth/login", json=user1_data) + token1 = login1.json()["access_token"] + headers1 = {"Authorization": f"Bearer {token1}"} + + client.post("/api/v1/boards", json={"title": "User 1 Board"}, headers=headers1) + + # Create user2 and boards + user2_data = {"email": "user2@example.com", "password": "Password456"} + client.post("/api/v1/auth/register", json=user2_data) + login2 = client.post("/api/v1/auth/login", json=user2_data) + token2 = login2.json()["access_token"] + headers2 = {"Authorization": f"Bearer {token2}"} + + client.post("/api/v1/boards", json={"title": "User 2 Board"}, headers=headers2) + + # User1 should only see their board + response1 = client.get("/api/v1/boards", headers=headers1) + boards1 = response1.json()["boards"] + assert len(boards1) == 1 + assert boards1[0]["title"] == "User 1 Board" + + # User2 should only see their board + response2 = client.get("/api/v1/boards", headers=headers2) + boards2 = response2.json()["boards"] + assert len(boards2) == 1 + assert boards2[0]["title"] == "User 2 Board" + + def test_users_cannot_access_each_others_boards_directly(self, client: TestClient): + """Test that users can't access boards they don't own.""" + # Create user1 and board + user1_data = {"email": "user1@example.com", "password": "Password123"} + client.post("/api/v1/auth/register", json=user1_data) + login1 = client.post("/api/v1/auth/login", json=user1_data) + token1 = login1.json()["access_token"] + headers1 = {"Authorization": f"Bearer {token1}"} + + create_response = client.post( + "/api/v1/boards", json={"title": "User 1 Board"}, headers=headers1 + ) + board_id = create_response.json()["id"] + + # Create user2 + user2_data = {"email": "user2@example.com", "password": "Password456"} + client.post("/api/v1/auth/register", json=user2_data) + login2 = client.post("/api/v1/auth/login", json=user2_data) + token2 = login2.json()["access_token"] + headers2 = {"Authorization": f"Bearer {token2}"} + + # User2 tries to access User1's board + response = client.get(f"/api/v1/boards/{board_id}", headers=headers2) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_users_cannot_update_each_others_boards(self, client: TestClient): + """Test that users can't update boards they don't own.""" + # Create user1 and board + user1_data = {"email": "user1@example.com", "password": "Password123"} + client.post("/api/v1/auth/register", json=user1_data) + login1 = client.post("/api/v1/auth/login", json=user1_data) + token1 = login1.json()["access_token"] + headers1 = {"Authorization": f"Bearer {token1}"} + + create_response = client.post( + "/api/v1/boards", json={"title": "User 1 Board"}, headers=headers1 + ) + board_id = create_response.json()["id"] + + # Create user2 + user2_data = {"email": "user2@example.com", "password": "Password456"} + client.post("/api/v1/auth/register", json=user2_data) + login2 = client.post("/api/v1/auth/login", json=user2_data) + token2 = login2.json()["access_token"] + headers2 = {"Authorization": f"Bearer {token2}"} + + # User2 tries to update User1's board + response = client.patch( + f"/api/v1/boards/{board_id}", json={"title": "Hacked Title"}, headers=headers2 + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Verify original board unchanged + original = client.get(f"/api/v1/boards/{board_id}", headers=headers1) + assert original.json()["title"] == "User 1 Board" + + def test_users_cannot_delete_each_others_boards(self, client: TestClient): + """Test that users can't delete boards they don't own.""" + # Create user1 and board + user1_data = {"email": "user1@example.com", "password": "Password123"} + client.post("/api/v1/auth/register", json=user1_data) + login1 = client.post("/api/v1/auth/login", json=user1_data) + token1 = login1.json()["access_token"] + headers1 = {"Authorization": f"Bearer {token1}"} + + create_response = client.post( + "/api/v1/boards", json={"title": "User 1 Board"}, headers=headers1 + ) + board_id = create_response.json()["id"] + + # Create user2 + user2_data = {"email": "user2@example.com", "password": "Password456"} + client.post("/api/v1/auth/register", json=user2_data) + login2 = client.post("/api/v1/auth/login", json=user2_data) + token2 = login2.json()["access_token"] + headers2 = {"Authorization": f"Bearer {token2}"} + + # User2 tries to delete User1's board + response = client.delete(f"/api/v1/boards/{board_id}", headers=headers2) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Verify board still exists for user1 + still_exists = client.get(f"/api/v1/boards/{board_id}", headers=headers1) + assert still_exists.status_code == status.HTTP_200_OK + + +class TestBoardCRUDFlow: + """Test complete board CRUD flow.""" + + def test_complete_board_lifecycle(self, authenticated_client: tuple[TestClient, dict]): + """Test create → read → update → delete flow.""" + client, headers = authenticated_client + + # CREATE + create_data = {"title": "My Board", "description": "Initial description"} + create_response = client.post("/api/v1/boards", json=create_data, headers=headers) + + assert create_response.status_code == status.HTTP_201_CREATED + board_id = create_response.json()["id"] + + # READ + get_response = client.get(f"/api/v1/boards/{board_id}", headers=headers) + + assert get_response.status_code == status.HTTP_200_OK + assert get_response.json()["title"] == "My Board" + + # UPDATE + update_data = {"title": "Updated Board", "description": "Updated description"} + update_response = client.patch( + f"/api/v1/boards/{board_id}", json=update_data, headers=headers + ) + + assert update_response.status_code == status.HTTP_200_OK + assert update_response.json()["title"] == "Updated Board" + + # DELETE + delete_response = client.delete(f"/api/v1/boards/{board_id}", headers=headers) + + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + + # VERIFY DELETED + get_deleted = client.get(f"/api/v1/boards/{board_id}", headers=headers) + assert get_deleted.status_code == status.HTTP_404_NOT_FOUND + + def test_board_appears_in_list_after_creation(self, authenticated_client: tuple[TestClient, dict]): + """Test that newly created board appears in list.""" + client, headers = authenticated_client + + # List should be empty + initial_list = client.get("/api/v1/boards", headers=headers) + assert initial_list.json()["total"] == 0 + + # Create board + client.post("/api/v1/boards", json={"title": "New Board"}, headers=headers) + + # List should now contain 1 board + updated_list = client.get("/api/v1/boards", headers=headers) + data = updated_list.json() + + assert data["total"] == 1 + assert data["boards"][0]["title"] == "New Board" + + def test_board_updates_reflect_in_list(self, authenticated_client: tuple[TestClient, dict]): + """Test that board updates are reflected in the list.""" + client, headers = authenticated_client + + # Create board + create_response = client.post( + "/api/v1/boards", json={"title": "Original"}, headers=headers + ) + board_id = create_response.json()["id"] + + # Update board + client.patch(f"/api/v1/boards/{board_id}", json={"title": "Updated"}, headers=headers) + + # Check list + list_response = client.get("/api/v1/boards", headers=headers) + boards = list_response.json()["boards"] + + assert len(boards) == 1 + assert boards[0]["title"] == "Updated" + + def test_viewport_state_persists(self, authenticated_client: tuple[TestClient, dict]): + """Test that viewport state persists across updates.""" + client, headers = authenticated_client + + # Create board + create_response = client.post( + "/api/v1/boards", json={"title": "Test Board"}, headers=headers + ) + board_id = create_response.json()["id"] + + # Update viewport + viewport1 = {"x": 100, "y": 100, "zoom": 2.0, "rotation": 90} + client.patch( + f"/api/v1/boards/{board_id}", json={"viewport_state": viewport1}, headers=headers + ) + + # Update title (shouldn't affect viewport) + client.patch(f"/api/v1/boards/{board_id}", json={"title": "New Title"}, headers=headers) + + # Get board and verify viewport persisted + get_response = client.get(f"/api/v1/boards/{board_id}", headers=headers) + data = get_response.json() + + assert data["title"] == "New Title" + assert data["viewport_state"]["x"] == 100 + assert data["viewport_state"]["zoom"] == 2.0 + diff --git a/backend/tests/boards/__init__.py b/backend/tests/boards/__init__.py new file mode 100644 index 0000000..92873f2 --- /dev/null +++ b/backend/tests/boards/__init__.py @@ -0,0 +1,2 @@ +"""Board module tests.""" + diff --git a/backend/tests/boards/test_repository.py b/backend/tests/boards/test_repository.py new file mode 100644 index 0000000..b6520a7 --- /dev/null +++ b/backend/tests/boards/test_repository.py @@ -0,0 +1,442 @@ +"""Unit tests for board repository.""" + +from uuid import uuid4 + +import pytest +from sqlalchemy.orm import Session + +from app.boards.repository import BoardRepository +from app.database.models.board import Board +from app.database.models.user import User + + +@pytest.fixture +def test_user(db: Session) -> User: + """Create a test user.""" + user = User(email="test@example.com", password_hash="hashed_password") + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture +def board_repo(db: Session) -> BoardRepository: + """Create a board repository instance.""" + return BoardRepository(db) + + +class TestCreateBoard: + """Test board creation.""" + + def test_create_board_minimal(self, board_repo: BoardRepository, test_user: User): + """Test creating board with only required fields.""" + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + assert board.id is not None + assert board.user_id == test_user.id + assert board.title == "Test Board" + assert board.description is None + assert board.is_deleted is False + assert board.created_at is not None + assert board.updated_at is not None + + def test_create_board_with_description(self, board_repo: BoardRepository, test_user: User): + """Test creating board with description.""" + board = board_repo.create_board( + user_id=test_user.id, title="Test Board", description="This is a test description" + ) + + assert board.description == "This is a test description" + + def test_create_board_default_viewport(self, board_repo: BoardRepository, test_user: User): + """Test that board is created with default viewport state.""" + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + assert board.viewport_state is not None + assert board.viewport_state["x"] == 0 + assert board.viewport_state["y"] == 0 + assert board.viewport_state["zoom"] == 1.0 + assert board.viewport_state["rotation"] == 0 + + def test_create_board_custom_viewport(self, board_repo: BoardRepository, test_user: User): + """Test creating board with custom viewport state.""" + custom_viewport = {"x": 100, "y": 200, "zoom": 2.0, "rotation": 45} + + board = board_repo.create_board( + user_id=test_user.id, title="Test Board", viewport_state=custom_viewport + ) + + assert board.viewport_state == custom_viewport + + def test_create_multiple_boards(self, board_repo: BoardRepository, test_user: User): + """Test creating multiple boards for same user.""" + board1 = board_repo.create_board(user_id=test_user.id, title="Board 1") + board2 = board_repo.create_board(user_id=test_user.id, title="Board 2") + board3 = board_repo.create_board(user_id=test_user.id, title="Board 3") + + assert board1.id != board2.id + assert board2.id != board3.id + assert all(b.user_id == test_user.id for b in [board1, board2, board3]) + + +class TestGetBoardById: + """Test retrieving board by ID.""" + + def test_get_existing_board(self, board_repo: BoardRepository, test_user: User): + """Test getting existing board owned by user.""" + created = board_repo.create_board(user_id=test_user.id, title="Test Board") + + retrieved = board_repo.get_board_by_id(board_id=created.id, user_id=test_user.id) + + assert retrieved is not None + assert retrieved.id == created.id + assert retrieved.title == created.title + + def test_get_nonexistent_board(self, board_repo: BoardRepository, test_user: User): + """Test getting board that doesn't exist.""" + fake_id = uuid4() + + result = board_repo.get_board_by_id(board_id=fake_id, user_id=test_user.id) + + assert result is None + + def test_get_board_wrong_owner(self, board_repo: BoardRepository, test_user: User, db: Session): + """Test that users can't access boards they don't own.""" + # Create another user + other_user = User(email="other@example.com", password_hash="hashed") + db.add(other_user) + db.commit() + db.refresh(other_user) + + # Create board owned by test_user + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + # Try to get with other_user + result = board_repo.get_board_by_id(board_id=board.id, user_id=other_user.id) + + assert result is None + + def test_get_deleted_board(self, board_repo: BoardRepository, test_user: User): + """Test that soft-deleted boards are not returned.""" + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + # Delete the board + board_repo.delete_board(board_id=board.id, user_id=test_user.id) + + # Try to get it + result = board_repo.get_board_by_id(board_id=board.id, user_id=test_user.id) + + assert result is None + + +class TestGetUserBoards: + """Test listing user's boards.""" + + def test_get_user_boards_empty(self, board_repo: BoardRepository, test_user: User): + """Test getting boards when user has none.""" + boards, total = board_repo.get_user_boards(user_id=test_user.id) + + assert boards == [] + assert total == 0 + + def test_get_user_boards_multiple(self, board_repo: BoardRepository, test_user: User): + """Test getting multiple boards.""" + board1 = board_repo.create_board(user_id=test_user.id, title="Board 1") + board2 = board_repo.create_board(user_id=test_user.id, title="Board 2") + board3 = board_repo.create_board(user_id=test_user.id, title="Board 3") + + boards, total = board_repo.get_user_boards(user_id=test_user.id) + + assert len(boards) == 3 + assert total == 3 + assert {b.id for b in boards} == {board1.id, board2.id, board3.id} + + def test_get_user_boards_pagination(self, board_repo: BoardRepository, test_user: User): + """Test pagination of board list.""" + # Create 5 boards + for i in range(5): + board_repo.create_board(user_id=test_user.id, title=f"Board {i}") + + # Get first 2 + boards_page1, total = board_repo.get_user_boards(user_id=test_user.id, limit=2, offset=0) + + assert len(boards_page1) == 2 + assert total == 5 + + # Get next 2 + boards_page2, total = board_repo.get_user_boards(user_id=test_user.id, limit=2, offset=2) + + assert len(boards_page2) == 2 + assert total == 5 + + # Ensure no overlap + page1_ids = {b.id for b in boards_page1} + page2_ids = {b.id for b in boards_page2} + assert page1_ids.isdisjoint(page2_ids) + + def test_get_user_boards_sorted_by_update(self, board_repo: BoardRepository, test_user: User): + """Test that boards are sorted by updated_at descending.""" + board1 = board_repo.create_board(user_id=test_user.id, title="Oldest") + board2 = board_repo.create_board(user_id=test_user.id, title="Middle") + board3 = board_repo.create_board(user_id=test_user.id, title="Newest") + + boards, _ = board_repo.get_user_boards(user_id=test_user.id) + + # Most recently updated should be first + assert boards[0].id == board3.id + assert boards[1].id == board2.id + assert boards[2].id == board1.id + + def test_get_user_boards_excludes_deleted(self, board_repo: BoardRepository, test_user: User): + """Test that soft-deleted boards are excluded.""" + board1 = board_repo.create_board(user_id=test_user.id, title="Board 1") + board2 = board_repo.create_board(user_id=test_user.id, title="Board 2") + board3 = board_repo.create_board(user_id=test_user.id, title="Board 3") + + # Delete board2 + board_repo.delete_board(board_id=board2.id, user_id=test_user.id) + + boards, total = board_repo.get_user_boards(user_id=test_user.id) + + assert len(boards) == 2 + assert total == 2 + assert {b.id for b in boards} == {board1.id, board3.id} + + def test_get_user_boards_isolation(self, board_repo: BoardRepository, test_user: User, db: Session): + """Test that users only see their own boards.""" + # Create another user + other_user = User(email="other@example.com", password_hash="hashed") + db.add(other_user) + db.commit() + db.refresh(other_user) + + # Create boards for both users + test_board = board_repo.create_board(user_id=test_user.id, title="Test Board") + other_board = board_repo.create_board(user_id=other_user.id, title="Other Board") + + # Get test_user's boards + test_boards, _ = board_repo.get_user_boards(user_id=test_user.id) + + assert len(test_boards) == 1 + assert test_boards[0].id == test_board.id + + # Get other_user's boards + other_boards, _ = board_repo.get_user_boards(user_id=other_user.id) + + assert len(other_boards) == 1 + assert other_boards[0].id == other_board.id + + +class TestUpdateBoard: + """Test board updates.""" + + def test_update_board_title(self, board_repo: BoardRepository, test_user: User): + """Test updating board title.""" + board = board_repo.create_board(user_id=test_user.id, title="Original Title") + + updated = board_repo.update_board( + board_id=board.id, user_id=test_user.id, title="Updated Title" + ) + + assert updated is not None + assert updated.title == "Updated Title" + assert updated.id == board.id + + def test_update_board_description(self, board_repo: BoardRepository, test_user: User): + """Test updating board description.""" + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + updated = board_repo.update_board( + board_id=board.id, user_id=test_user.id, description="New description" + ) + + assert updated is not None + assert updated.description == "New description" + + def test_update_board_viewport(self, board_repo: BoardRepository, test_user: User): + """Test updating viewport state.""" + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + new_viewport = {"x": 100, "y": 200, "zoom": 1.5, "rotation": 90} + updated = board_repo.update_board( + board_id=board.id, user_id=test_user.id, viewport_state=new_viewport + ) + + assert updated is not None + assert updated.viewport_state == new_viewport + + def test_update_multiple_fields(self, board_repo: BoardRepository, test_user: User): + """Test updating multiple fields at once.""" + board = board_repo.create_board(user_id=test_user.id, title="Original") + + updated = board_repo.update_board( + board_id=board.id, + user_id=test_user.id, + title="Updated Title", + description="Updated Description", + viewport_state={"x": 50, "y": 50, "zoom": 2.0, "rotation": 45}, + ) + + assert updated is not None + assert updated.title == "Updated Title" + assert updated.description == "Updated Description" + assert updated.viewport_state["zoom"] == 2.0 + + def test_update_nonexistent_board(self, board_repo: BoardRepository, test_user: User): + """Test updating board that doesn't exist.""" + fake_id = uuid4() + + result = board_repo.update_board(board_id=fake_id, user_id=test_user.id, title="New Title") + + assert result is None + + def test_update_board_wrong_owner(self, board_repo: BoardRepository, test_user: User, db: Session): + """Test that users can't update boards they don't own.""" + # Create another user + other_user = User(email="other@example.com", password_hash="hashed") + db.add(other_user) + db.commit() + db.refresh(other_user) + + # Create board owned by test_user + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + # Try to update with other_user + result = board_repo.update_board( + board_id=board.id, user_id=other_user.id, title="Hacked Title" + ) + + assert result is None + + # Verify original board unchanged + original = board_repo.get_board_by_id(board_id=board.id, user_id=test_user.id) + assert original.title == "Test Board" + + def test_update_board_partial_update(self, board_repo: BoardRepository, test_user: User): + """Test that partial updates don't affect unspecified fields.""" + board = board_repo.create_board( + user_id=test_user.id, title="Original Title", description="Original Description" + ) + + # Update only title + updated = board_repo.update_board(board_id=board.id, user_id=test_user.id, title="New Title") + + assert updated is not None + assert updated.title == "New Title" + assert updated.description == "Original Description" # Should be unchanged + + +class TestDeleteBoard: + """Test board deletion.""" + + def test_delete_board_success(self, board_repo: BoardRepository, test_user: User): + """Test successfully deleting a board.""" + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + success = board_repo.delete_board(board_id=board.id, user_id=test_user.id) + + assert success is True + + def test_delete_board_soft_delete(self, board_repo: BoardRepository, test_user: User, db: Session): + """Test that delete is a soft delete (sets flag instead of removing).""" + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + board_repo.delete_board(board_id=board.id, user_id=test_user.id) + + # Board should still exist in database but marked as deleted + db_board = db.get(Board, board.id) + assert db_board is not None + assert db_board.is_deleted is True + + def test_delete_board_not_in_listings(self, board_repo: BoardRepository, test_user: User): + """Test that deleted boards don't appear in listings.""" + board1 = board_repo.create_board(user_id=test_user.id, title="Board 1") + board2 = board_repo.create_board(user_id=test_user.id, title="Board 2") + + # Delete board1 + board_repo.delete_board(board_id=board1.id, user_id=test_user.id) + + boards, total = board_repo.get_user_boards(user_id=test_user.id) + + assert len(boards) == 1 + assert total == 1 + assert boards[0].id == board2.id + + def test_delete_nonexistent_board(self, board_repo: BoardRepository, test_user: User): + """Test deleting board that doesn't exist.""" + fake_id = uuid4() + + success = board_repo.delete_board(board_id=fake_id, user_id=test_user.id) + + assert success is False + + def test_delete_board_wrong_owner(self, board_repo: BoardRepository, test_user: User, db: Session): + """Test that users can't delete boards they don't own.""" + # Create another user + other_user = User(email="other@example.com", password_hash="hashed") + db.add(other_user) + db.commit() + db.refresh(other_user) + + # Create board owned by test_user + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + # Try to delete with other_user + success = board_repo.delete_board(board_id=board.id, user_id=other_user.id) + + assert success is False + + # Verify board still exists for original owner + still_exists = board_repo.get_board_by_id(board_id=board.id, user_id=test_user.id) + assert still_exists is not None + assert still_exists.is_deleted is False + + +class TestBoardExists: + """Test board existence check.""" + + def test_board_exists_true(self, board_repo: BoardRepository, test_user: User): + """Test checking if board exists.""" + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + exists = board_repo.board_exists(board_id=board.id, user_id=test_user.id) + + assert exists is True + + def test_board_exists_false(self, board_repo: BoardRepository, test_user: User): + """Test checking if board doesn't exist.""" + fake_id = uuid4() + + exists = board_repo.board_exists(board_id=fake_id, user_id=test_user.id) + + assert exists is False + + def test_board_exists_wrong_owner(self, board_repo: BoardRepository, test_user: User, db: Session): + """Test that board_exists returns False for wrong owner.""" + # Create another user + other_user = User(email="other@example.com", password_hash="hashed") + db.add(other_user) + db.commit() + db.refresh(other_user) + + # Create board owned by test_user + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + # Check with wrong owner + exists = board_repo.board_exists(board_id=board.id, user_id=other_user.id) + + assert exists is False + + def test_board_exists_deleted(self, board_repo: BoardRepository, test_user: User): + """Test that deleted boards return False for existence check.""" + board = board_repo.create_board(user_id=test_user.id, title="Test Board") + + # Delete board + board_repo.delete_board(board_id=board.id, user_id=test_user.id) + + # Check existence + exists = board_repo.board_exists(board_id=board.id, user_id=test_user.id) + + assert exists is False + diff --git a/frontend/src/routes/boards/+page.svelte b/frontend/src/routes/boards/+page.svelte new file mode 100644 index 0000000..14119ed --- /dev/null +++ b/frontend/src/routes/boards/+page.svelte @@ -0,0 +1,218 @@ + + +
+ + + {#if $boardsError} +
+ + {$boardsError} + +
+ {/if} + + {#if $boardsLoading} +
+
+

Loading boards...

+
+ {:else if $boardsList.length === 0} +
+
📋
+

No boards yet

+

Create your first reference board to get started

+ +
+ {:else} +
+ {#each $boardsList as board (board.id)} + handleDelete(board.id)} /> + {/each} +
+ {/if} +
+ +{#if showCreateModal} + +{/if} + + + diff --git a/frontend/src/routes/boards/[id]/edit/+page.svelte b/frontend/src/routes/boards/[id]/edit/+page.svelte new file mode 100644 index 0000000..9a55960 --- /dev/null +++ b/frontend/src/routes/boards/[id]/edit/+page.svelte @@ -0,0 +1,381 @@ + + + + Edit Board - Reference Board Viewer + + +
+
+ + + {#if isLoading} +
+
+

Loading board...

+
+ {:else if errors.general} +
+ + {errors.general} + +
+ {:else} +
+
+ + + {#if errors.title} + {errors.title} + {:else} + {title.length}/255 characters + {/if} +
+ +
+ +