Skip to content

Commit 865ae42

Browse files
authored
test: adding backend/e2e tests (#97)
* missing e2e tests * all evetnt types as strs -> EventType; updated usage places * updated tests - tests for happy paths are using pydantic models from now on * resource cleaner fix: getting k8s stuff from DI * tests update - removed kafka prefix, simplified multithread testing in conftest * removed integration tests, moved all of them to e2e, also updated docs * sse status enum, fixed tests * fixes + no if status=200 checks, using asserts * routing notif service same as sse bridge and event store consumer - via Lifecycle * fixed problem with sagas
1 parent 96f3ad4 commit 865ae42

166 files changed

Lines changed: 10556 additions & 5343 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/stack-tests.yml

Lines changed: 3 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -185,76 +185,7 @@ jobs:
185185
path: /tmp/all-images.tar.zst
186186
retention-days: 1
187187

188-
# Three parallel test jobs
189-
backend-integration:
190-
name: Backend Integration Tests
191-
needs: [build-images]
192-
runs-on: ubuntu-latest
193-
steps:
194-
- uses: actions/checkout@v6
195-
196-
- name: Cache and load Docker images
197-
uses: ./.github/actions/docker-cache
198-
with:
199-
images: ${{ env.MONGO_IMAGE }} ${{ env.REDIS_IMAGE }} ${{ env.KAFKA_IMAGE }} ${{ env.ZOOKEEPER_IMAGE }} ${{ env.SCHEMA_REGISTRY_IMAGE }}
200-
201-
- name: Download built images
202-
uses: actions/download-artifact@v7
203-
with:
204-
name: docker-images
205-
path: /tmp
206-
207-
- name: Load built images
208-
run: zstd -d -c /tmp/all-images.tar.zst | docker load
209-
210-
- name: Setup k3s
211-
uses: ./.github/actions/k3s-setup
212-
213-
- name: Use test environment config
214-
run: cp backend/.env.test backend/.env
215-
216-
- name: Start stack
217-
run: ./deploy.sh dev --wait
218-
219-
- name: Run integration tests
220-
timeout-minutes: 10
221-
run: |
222-
docker compose exec -T -e TEST_RUN_ID=integration backend \
223-
uv run pytest tests/integration -v -rs \
224-
--durations=0 \
225-
--cov=app \
226-
--cov-report=xml:coverage-integration.xml \
227-
--cov-report=term
228-
229-
- name: Copy coverage
230-
if: always()
231-
run: docker compose cp backend:/app/coverage-integration.xml backend/coverage-integration.xml || true
232-
233-
- name: Upload coverage to Codecov
234-
uses: codecov/codecov-action@v5
235-
if: always()
236-
with:
237-
token: ${{ secrets.CODECOV_TOKEN }}
238-
files: backend/coverage-integration.xml
239-
flags: backend-integration
240-
name: backend-integration-coverage
241-
fail_ci_if_error: false
242-
243-
- name: Collect logs on failure
244-
if: failure()
245-
run: |
246-
mkdir -p logs
247-
docker compose logs > logs/docker-compose.log 2>&1
248-
docker compose logs backend > logs/backend.log 2>&1
249-
docker compose logs kafka > logs/kafka.log 2>&1
250-
251-
- name: Upload logs
252-
if: failure()
253-
uses: actions/upload-artifact@v6
254-
with:
255-
name: backend-integration-logs
256-
path: logs/
257-
188+
# Parallel test jobs (backend-e2e, frontend-e2e)
258189
backend-e2e:
259190
name: Backend E2E Tests
260191
needs: [build-images]
@@ -289,9 +220,9 @@ jobs:
289220
run: docker compose exec -T backend uv run python scripts/seed_users.py
290221

291222
- name: Run E2E tests
292-
timeout-minutes: 10
223+
timeout-minutes: 15
293224
run: |
294-
docker compose exec -T -e TEST_RUN_ID=e2e backend \
225+
docker compose exec -T backend \
295226
uv run pytest tests/e2e -v -rs \
296227
--durations=0 \
297228
--cov=app \

backend/app/api/routes/admin/events.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ async def export_events_csv(
7777
) -> StreamingResponse:
7878
try:
7979
export_filter = EventFilter(
80-
event_types=[str(et) for et in event_types] if event_types else None,
80+
event_types=event_types,
8181
start_time=start_time,
8282
end_time=end_time,
8383
)
@@ -107,7 +107,7 @@ async def export_events_json(
107107
"""Export events as JSON with comprehensive filtering."""
108108
try:
109109
export_filter = EventFilter(
110-
event_types=[str(et) for et in event_types] if event_types else None,
110+
event_types=event_types,
111111
aggregate_id=aggregate_id,
112112
correlation_id=correlation_id,
113113
user_id=user_id,

backend/app/api/routes/admin/users.py

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,19 @@
77
from app.api.dependencies import admin_user
88
from app.db.repositories.admin.admin_user_repository import AdminUserRepository
99
from app.domain.enums.user import UserRole
10-
from app.domain.rate_limit import UserRateLimit
10+
from app.domain.rate_limit import RateLimitRule, UserRateLimit
1111
from app.domain.user import UserUpdate as DomainUserUpdate
1212
from app.schemas_pydantic.admin_user_overview import (
1313
AdminUserOverview,
1414
DerivedCounts,
1515
RateLimitSummary,
1616
)
17-
from app.schemas_pydantic.events import EventResponse, EventStatistics
17+
from app.schemas_pydantic.events import EventStatistics
1818
from app.schemas_pydantic.user import (
1919
DeleteUserResponse,
2020
MessageResponse,
2121
PasswordResetRequest,
22+
RateLimitUpdateRequest,
2223
RateLimitUpdateResponse,
2324
UserCreate,
2425
UserListResponse,
@@ -27,7 +28,6 @@
2728
UserUpdate,
2829
)
2930
from app.services.admin import AdminUserService
30-
from app.services.rate_limit_service import RateLimitService
3131

3232
router = APIRouter(
3333
prefix="/admin/users", tags=["admin", "users"], route_class=DishkaRoute, dependencies=[Depends(admin_user)]
@@ -38,7 +38,6 @@
3838
async def list_users(
3939
admin: Annotated[UserResponse, Depends(admin_user)],
4040
admin_user_service: FromDishka[AdminUserService],
41-
rate_limit_service: FromDishka[RateLimitService],
4241
limit: int = Query(default=100, le=1000),
4342
offset: int = Query(default=0, ge=0),
4443
search: str | None = None,
@@ -51,24 +50,8 @@ async def list_users(
5150
search=search,
5251
role=role,
5352
)
54-
55-
summaries = await rate_limit_service.get_user_rate_limit_summaries([u.user_id for u in result.users])
56-
user_responses: list[UserResponse] = []
57-
for user in result.users:
58-
user_response = UserResponse.model_validate(user)
59-
summary = summaries.get(user.user_id)
60-
if summary:
61-
user_response = user_response.model_copy(
62-
update={
63-
"bypass_rate_limit": summary.bypass_rate_limit,
64-
"global_multiplier": summary.global_multiplier,
65-
"has_custom_limits": summary.has_custom_limits,
66-
}
67-
)
68-
user_responses.append(user_response)
69-
7053
return UserListResponse(
71-
users=user_responses,
54+
users=[UserResponse.model_validate(u) for u in result.users],
7255
total=result.total,
7356
offset=result.offset,
7457
limit=result.limit,
@@ -119,7 +102,7 @@ async def get_user_overview(
119102
stats=EventStatistics.model_validate(domain.stats),
120103
derived_counts=DerivedCounts.model_validate(domain.derived_counts),
121104
rate_limit_summary=RateLimitSummary.model_validate(domain.rate_limit_summary),
122-
recent_events=[EventResponse.model_validate(e).model_dump() for e in domain.recent_events],
105+
recent_events=domain.recent_events,
123106
)
124107

125108

@@ -165,13 +148,19 @@ async def delete_user(
165148
if admin.user_id == user_id:
166149
raise HTTPException(status_code=400, detail="Cannot delete your own account")
167150

168-
deleted_counts = await admin_user_service.delete_user(
151+
result = await admin_user_service.delete_user(
169152
admin_username=admin.username, user_id=user_id, cascade=cascade
170153
)
171-
if deleted_counts.get("user", 0) == 0:
172-
raise HTTPException(status_code=500, detail="Failed to delete user")
173-
174-
return DeleteUserResponse(message=f"User {user_id} deleted successfully", deleted_counts=deleted_counts)
154+
return DeleteUserResponse(
155+
message=f"User {user_id} deleted successfully",
156+
user_deleted=result.user_deleted,
157+
executions=result.executions,
158+
saved_scripts=result.saved_scripts,
159+
notifications=result.notifications,
160+
user_settings=result.user_settings,
161+
events=result.events,
162+
sagas=result.sagas,
163+
)
175164

176165

177166
@router.post("/{user_id}/reset-password", response_model=MessageResponse)
@@ -204,10 +193,15 @@ async def update_user_rate_limits(
204193
admin: Annotated[UserResponse, Depends(admin_user)],
205194
admin_user_service: FromDishka[AdminUserService],
206195
user_id: str,
207-
rate_limit_config: UserRateLimit,
196+
request: RateLimitUpdateRequest,
208197
) -> RateLimitUpdateResponse:
198+
config = UserRateLimit(
199+
user_id=user_id,
200+
rules=[RateLimitRule(**r.model_dump()) for r in request.rules],
201+
**request.model_dump(exclude={"rules"}),
202+
)
209203
result = await admin_user_service.update_user_rate_limits(
210-
admin_username=admin.username, user_id=user_id, config=rate_limit_config
204+
admin_username=admin.username, user_id=user_id, config=config
211205
)
212206
return RateLimitUpdateResponse.model_validate(result)
213207

backend/app/api/routes/events.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
from dishka import FromDishka
77
from dishka.integrations.fastapi import DishkaRoute
88
from fastapi import APIRouter, Depends, HTTPException, Query, Request
9+
from pydantic import TypeAdapter
910

1011
from app.api.dependencies import admin_user, current_user
1112
from app.core.correlation import CorrelationContext
1213
from app.core.utils import get_client_ip
1314
from app.domain.enums.common import SortOrder
15+
from app.domain.enums.events import EventType
16+
from app.domain.enums.user import UserRole
1417
from app.domain.events.event_models import EventFilter
15-
from app.domain.events.typed import BaseEvent, EventMetadata
18+
from app.domain.events.typed import BaseEvent, DomainEvent, EventMetadata
1619
from app.schemas_pydantic.events import (
1720
DeleteEventResponse,
1821
EventAggregationRequest,
@@ -26,9 +29,12 @@
2629
)
2730
from app.schemas_pydantic.user import UserResponse
2831
from app.services.event_service import EventService
32+
from app.services.execution_service import ExecutionService
2933
from app.services.kafka_event_service import KafkaEventService
3034
from app.settings import Settings
3135

36+
_event_response_list_adapter: TypeAdapter[list[EventResponse]] = TypeAdapter(list[EventResponse])
37+
3238
router = APIRouter(prefix="/events", tags=["events"], route_class=DishkaRoute)
3339

3440

@@ -37,10 +43,16 @@ async def get_execution_events(
3743
execution_id: str,
3844
current_user: Annotated[UserResponse, Depends(current_user)],
3945
event_service: FromDishka[EventService],
46+
execution_service: FromDishka[ExecutionService],
4047
include_system_events: bool = Query(False, description="Include system-generated events"),
4148
limit: int = Query(100, ge=1, le=1000),
4249
skip: int = Query(0, ge=0),
4350
) -> EventListResponse:
51+
# Check execution ownership first (before checking events)
52+
execution = await execution_service.get_execution_result(execution_id)
53+
if execution.user_id and execution.user_id != current_user.user_id and current_user.role != UserRole.ADMIN:
54+
raise HTTPException(status_code=403, detail="Access denied")
55+
4456
result = await event_service.get_execution_events(
4557
execution_id=execution_id,
4658
user_id=current_user.user_id,
@@ -53,10 +65,8 @@ async def get_execution_events(
5365
if result is None:
5466
raise HTTPException(status_code=403, detail="Access denied")
5567

56-
event_responses = [EventResponse.model_validate(event) for event in result.events]
57-
5868
return EventListResponse(
59-
events=event_responses,
69+
events=_event_response_list_adapter.validate_python(result.events, from_attributes=True),
6070
total=result.total,
6171
limit=limit,
6272
skip=skip,
@@ -68,7 +78,7 @@ async def get_execution_events(
6878
async def get_user_events(
6979
current_user: Annotated[UserResponse, Depends(current_user)],
7080
event_service: FromDishka[EventService],
71-
event_types: List[str] | None = Query(None),
81+
event_types: List[EventType] | None = Query(None),
7282
start_time: datetime | None = Query(None),
7383
end_time: datetime | None = Query(None),
7484
limit: int = Query(100, ge=1, le=1000),
@@ -86,10 +96,12 @@ async def get_user_events(
8696
sort_order=sort_order,
8797
)
8898

89-
event_responses = [EventResponse.model_validate(event) for event in result.events]
90-
9199
return EventListResponse(
92-
events=event_responses, total=result.total, limit=limit, skip=skip, has_more=result.has_more
100+
events=_event_response_list_adapter.validate_python(result.events, from_attributes=True),
101+
total=result.total,
102+
limit=limit,
103+
skip=skip,
104+
has_more=result.has_more,
93105
)
94106

95107

@@ -100,7 +112,7 @@ async def query_events(
100112
event_service: FromDishka[EventService],
101113
) -> EventListResponse:
102114
event_filter = EventFilter(
103-
event_types=[str(et) for et in filter_request.event_types] if filter_request.event_types else None,
115+
event_types=filter_request.event_types,
104116
aggregate_id=filter_request.aggregate_id,
105117
correlation_id=filter_request.correlation_id,
106118
user_id=filter_request.user_id,
@@ -121,10 +133,12 @@ async def query_events(
121133
if result is None:
122134
raise HTTPException(status_code=403, detail="Cannot query other users' events")
123135

124-
event_responses = [EventResponse.model_validate(event) for event in result.events]
125-
126136
return EventListResponse(
127-
events=event_responses, total=result.total, limit=result.limit, skip=result.skip, has_more=result.has_more
137+
events=_event_response_list_adapter.validate_python(result.events, from_attributes=True),
138+
total=result.total,
139+
limit=result.limit,
140+
skip=result.skip,
141+
has_more=result.has_more,
128142
)
129143

130144

@@ -146,10 +160,8 @@ async def get_events_by_correlation(
146160
skip=skip,
147161
)
148162

149-
event_responses = [EventResponse.model_validate(event) for event in result.events]
150-
151163
return EventListResponse(
152-
events=event_responses,
164+
events=_event_response_list_adapter.validate_python(result.events, from_attributes=True),
153165
total=result.total,
154166
limit=limit,
155167
skip=skip,
@@ -177,10 +189,8 @@ async def get_current_request_events(
177189
skip=skip,
178190
)
179191

180-
event_responses = [EventResponse.model_validate(event) for event in result.events]
181-
182192
return EventListResponse(
183-
events=event_responses,
193+
events=_event_response_list_adapter.validate_python(result.events, from_attributes=True),
184194
total=result.total,
185195
limit=limit,
186196
skip=skip,
@@ -212,15 +222,15 @@ async def get_event_statistics(
212222
return EventStatistics.model_validate(stats)
213223

214224

215-
@router.get("/{event_id}", response_model=EventResponse)
225+
@router.get("/{event_id}", response_model=DomainEvent)
216226
async def get_event(
217227
event_id: str, current_user: Annotated[UserResponse, Depends(current_user)], event_service: FromDishka[EventService]
218-
) -> EventResponse:
228+
) -> DomainEvent:
219229
"""Get a specific event by ID"""
220230
event = await event_service.get_event(event_id=event_id, user_id=current_user.user_id, user_role=current_user.role)
221231
if event is None:
222232
raise HTTPException(status_code=404, detail="Event not found")
223-
return EventResponse.model_validate(event)
233+
return event
224234

225235

226236
@router.post("/publish", response_model=PublishEventResponse)

0 commit comments

Comments
 (0)