phase 4
This commit is contained in:
558
backend/tests/api/test_boards.py
Normal file
558
backend/tests/api/test_boards.py
Normal 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
|
||||
|
||||
2
backend/tests/boards/__init__.py
Normal file
2
backend/tests/boards/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Board module tests."""
|
||||
|
||||
442
backend/tests/boards/test_repository.py
Normal file
442
backend/tests/boards/test_repository.py
Normal file
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user