559 lines
21 KiB
Python
559 lines
21 KiB
Python
"""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
|
|
|