"""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