This commit is contained in:
Danilo Reyes
2025-11-02 01:01:38 -06:00
parent b0e22af242
commit 48020b6f42
8 changed files with 2473 additions and 17 deletions

View File

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