feat: add schedule items CRUD API

- Add ScheduleItem Pydantic schemas with metadata support
- Add repository layer with CRUD operations
- Add service layer with authorization
- Add FastAPI router with all endpoints
- Add unit and integration tests
- Update API documentation
This commit is contained in:
qzl
2026-02-28 11:03:29 +08:00
parent dbd3f68dd4
commit 50b38de488
12 changed files with 1114 additions and 0 deletions
@@ -0,0 +1,205 @@
from datetime import datetime, timezone
from typing import Callable
from uuid import UUID, uuid4
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app import app
from core.auth.models import CurrentUser
from v1.schedule_items.dependencies import get_schedule_item_service
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemListRequest,
ScheduleItemResponse,
ScheduleItemSourceType,
ScheduleItemStatus,
ScheduleItemUpdateRequest,
)
from v1.schedule_items.service import ScheduleItemService
class FakeScheduleItemService:
def __init__(self, item: ScheduleItemResponse | None) -> None:
self._item = item
async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse:
if not self._item:
raise HTTPException(status_code=503, detail="Store unavailable")
return self._item
async def get_by_id(self, item_id: UUID) -> ScheduleItemResponse:
if not self._item or str(self._item.id) != str(item_id):
raise HTTPException(status_code=404, detail="Schedule item not found")
return self._item
async def update(
self, item_id: UUID, request: ScheduleItemUpdateRequest
) -> ScheduleItemResponse:
if not self._item or str(self._item.id) != str(item_id):
raise HTTPException(status_code=404, detail="Schedule item not found")
return self._item
async def delete(self, item_id: UUID) -> None:
if not self._item or str(self._item.id) != str(item_id):
raise HTTPException(status_code=404, detail="Schedule item not found")
async def list_by_date_range(
self, request: ScheduleItemListRequest
) -> list[ScheduleItemResponse]:
return [self._item] if self._item else []
def _override_schedule_item_service(
service: FakeScheduleItemService,
) -> Callable[[], ScheduleItemService]:
def _get_service() -> ScheduleItemService:
return service # type: ignore[return-value]
return _get_service
def _override_current_user(user_id: UUID) -> Callable[[], CurrentUser]:
def _get_user() -> CurrentUser:
return CurrentUser(id=user_id)
return _get_user
def test_create_schedule_item_returns_201() -> None:
item = ScheduleItemResponse(
id=uuid4(),
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="UTC",
status=ScheduleItemStatus.ACTIVE,
source_type=ScheduleItemSourceType.MANUAL,
created_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc),
)
app.dependency_overrides[get_schedule_item_service] = (
_override_schedule_item_service(FakeScheduleItemService(item))
)
client = TestClient(app)
try:
response = client.post(
"/api/v1/schedule-items",
json={
"title": "Test Event",
"start_at": "2026-02-28T16:00:00Z",
},
)
assert response.status_code == 201
finally:
app.dependency_overrides = {}
def test_list_schedule_items_returns_200() -> None:
item = ScheduleItemResponse(
id=uuid4(),
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="UTC",
status=ScheduleItemStatus.ACTIVE,
source_type=ScheduleItemSourceType.MANUAL,
created_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc),
)
app.dependency_overrides[get_schedule_item_service] = (
_override_schedule_item_service(FakeScheduleItemService(item))
)
client = TestClient(app)
try:
response = client.get(
"/api/v1/schedule-items",
params={
"start_at": "2026-02-01T00:00:00Z",
"end_at": "2026-02-28T23:59:59Z",
},
)
assert response.status_code == 200
assert isinstance(response.json(), list)
finally:
app.dependency_overrides = {}
def test_get_schedule_item_returns_200() -> None:
item_id = uuid4()
item = ScheduleItemResponse(
id=item_id,
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="UTC",
status=ScheduleItemStatus.ACTIVE,
source_type=ScheduleItemSourceType.MANUAL,
created_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc),
)
app.dependency_overrides[get_schedule_item_service] = (
_override_schedule_item_service(FakeScheduleItemService(item))
)
client = TestClient(app)
try:
response = client.get(f"/api/v1/schedule-items/{item_id}")
assert response.status_code == 200
finally:
app.dependency_overrides = {}
def test_update_schedule_item_returns_200() -> None:
item_id = uuid4()
item = ScheduleItemResponse(
id=item_id,
title="Updated Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="UTC",
status=ScheduleItemStatus.ACTIVE,
source_type=ScheduleItemSourceType.MANUAL,
created_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc),
)
app.dependency_overrides[get_schedule_item_service] = (
_override_schedule_item_service(FakeScheduleItemService(item))
)
client = TestClient(app)
try:
response = client.patch(
f"/api/v1/schedule-items/{item_id}",
json={"title": "Updated Event"},
)
assert response.status_code == 200
finally:
app.dependency_overrides = {}
def test_delete_schedule_item_returns_204() -> None:
item_id = uuid4()
item = ScheduleItemResponse(
id=item_id,
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="UTC",
status=ScheduleItemStatus.ACTIVE,
source_type=ScheduleItemSourceType.MANUAL,
created_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc),
)
app.dependency_overrides[get_schedule_item_service] = (
_override_schedule_item_service(FakeScheduleItemService(item))
)
client = TestClient(app)
try:
response = client.delete(f"/api/v1/schedule-items/{item_id}")
assert response.status_code == 204
finally:
app.dependency_overrides = {}
@@ -0,0 +1,88 @@
from datetime import datetime, timezone
import pytest
from pydantic import ValidationError
from v1.schedule_items.schemas import (
AttachmentType,
ScheduleItemCreateRequest,
ScheduleItemMetadata,
ScheduleItemMetadataAttachment,
ScheduleItemUpdateRequest,
)
def test_create_request_valid() -> None:
request = ScheduleItemCreateRequest(
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
)
assert request.title == "Test Event"
assert request.timezone == "UTC"
def test_create_request_with_end_at() -> None:
request = ScheduleItemCreateRequest(
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 2, 28, 17, 30, 0, tzinfo=timezone.utc),
)
assert request.end_at is not None
def test_create_request_invalid_title_empty() -> None:
with pytest.raises(ValidationError):
ScheduleItemCreateRequest(
title="",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
)
def test_create_request_invalid_title_too_long() -> None:
with pytest.raises(ValidationError):
ScheduleItemCreateRequest(
title="x" * 256,
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
)
def test_create_request_with_metadata() -> None:
metadata = ScheduleItemMetadata(
color="#FF6B6B",
location="Meeting Room A",
notes="Bring documents",
attachments=[],
)
request = ScheduleItemCreateRequest(
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
metadata=metadata,
)
assert request.metadata is not None
assert request.metadata.color == "#FF6B6B"
def test_update_request_partial() -> None:
request = ScheduleItemUpdateRequest(title="Updated Title")
assert request.title == "Updated Title"
assert request.description is None
def test_metadata_attachment_document() -> None:
attachment = ScheduleItemMetadataAttachment(
name="document.pdf",
type=AttachmentType.DOCUMENT,
url="https://example.com/doc.pdf",
)
assert attachment.type == AttachmentType.DOCUMENT
assert attachment.url == "https://example.com/doc.pdf"
def test_metadata_attachment_reminder() -> None:
attachment = ScheduleItemMetadataAttachment(
name="reminder",
type=AttachmentType.REMINDER,
content="Don't forget!",
)
assert attachment.type == AttachmentType.REMINDER
assert attachment.content == "Don't forget!"
@@ -0,0 +1,185 @@
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException
from core.auth.models import CurrentUser
from models.schedule_items import (
ScheduleItem,
ScheduleItemSourceType,
ScheduleItemStatus,
)
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemUpdateRequest,
)
from v1.schedule_items.service import ScheduleItemService
def _create_mock_schedule_item(
item_id: UUID = uuid4(),
owner_id: UUID = UUID("00000000-0000-0000-0000-000000000001"),
title: str = "Test Event",
) -> ScheduleItem:
item = MagicMock(spec=ScheduleItem)
item.id = item_id
item.owner_id = owner_id
item.title = title
item.description = None
item.start_at = datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc)
item.end_at = datetime(2026, 2, 28, 17, 0, 0, tzinfo=timezone.utc)
item.timezone = "UTC"
item.extra_metadata = {}
item.source_type = ScheduleItemSourceType.MANUAL
item.status = ScheduleItemStatus.ACTIVE
item.created_at = datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc)
item.updated_at = datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc)
return item
class FakeRepo:
def __init__(self, item: ScheduleItem | None) -> None:
self._item = item
async def get_by_item_id(
self, item_id: UUID, owner_id: UUID
) -> ScheduleItem | None:
if self._item and item_id == self._item.id:
return self._item
return None
async def create(self, data: dict) -> ScheduleItem:
return _create_mock_schedule_item(
owner_id=data["owner_id"],
title=data["title"],
)
async def update_by_item_id(
self, item_id: UUID, owner_id: UUID, data: dict
) -> ScheduleItem | None:
if not self._item or item_id != self._item.id:
return None
if "title" in data:
self._item.title = data["title"]
return self._item
async def delete_by_item_id(
self, item_id: UUID, owner_id: UUID
) -> ScheduleItem | None:
if not self._item or item_id != self._item.id:
return None
return self._item
async def list_by_date_range(
self, owner_id: UUID, start_at: datetime, end_at: datetime
) -> list[ScheduleItem]:
return [self._item] if self._item else []
@pytest.fixture
def mock_session() -> AsyncMock:
session = AsyncMock()
session.commit = AsyncMock()
session.rollback = AsyncMock()
return session
@pytest.mark.asyncio
async def test_create_success(mock_session: AsyncMock) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
request = ScheduleItemCreateRequest(
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
)
service = ScheduleItemService(
repository=FakeRepo(None),
session=mock_session,
current_user=CurrentUser(id=user_id),
)
result = await service.create(request)
assert result.title == "Test Event"
mock_session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_invalid_end_at(mock_session: AsyncMock) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
request = ScheduleItemCreateRequest(
title="Test Event",
start_at=datetime(2026, 2, 28, 17, 0, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
)
service = ScheduleItemService(
repository=FakeRepo(None),
session=mock_session,
current_user=CurrentUser(id=user_id),
)
with pytest.raises(HTTPException) as exc_info:
await service.create(request)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_get_by_id_success(mock_session: AsyncMock) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
item = _create_mock_schedule_item()
service = ScheduleItemService(
repository=FakeRepo(item),
session=mock_session,
current_user=CurrentUser(id=user_id),
)
result = await service.get_by_id(item.id)
assert result.id == item.id
@pytest.mark.asyncio
async def test_get_by_id_not_found(mock_session: AsyncMock) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
service = ScheduleItemService(
repository=FakeRepo(None),
session=mock_session,
current_user=CurrentUser(id=user_id),
)
with pytest.raises(HTTPException) as exc_info:
await service.get_by_id(uuid4())
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_update_success(mock_session: AsyncMock) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
item = _create_mock_schedule_item()
service = ScheduleItemService(
repository=FakeRepo(item),
session=mock_session,
current_user=CurrentUser(id=user_id),
)
result = await service.update(item.id, ScheduleItemUpdateRequest(title="Updated"))
assert result.title == "Updated"
@pytest.mark.asyncio
async def test_delete_success(mock_session: AsyncMock) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
item = _create_mock_schedule_item()
service = ScheduleItemService(
repository=FakeRepo(item),
session=mock_session,
current_user=CurrentUser(id=user_id),
)
await service.delete(item.id)
mock_session.commit.assert_awaited_once()