basyx commited on
Commit
7cc81cb
·
verified ·
1 Parent(s): 61c310e

Upload 437 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +15 -0
  2. api/__init__.py +13 -0
  3. app/api/api_keys.py +0 -1
  4. app/mcp/server.py +2 -0
  5. app/mcp/tools/collaboration.py +30 -20
  6. app/projects/api.py +31 -8
  7. app/projects/migrations/0014_approval_workspace_integrity.sql +42 -0
  8. app/projects/repositories/approval_repository.py +19 -7
  9. app/projects/repositories/collaboration_repository.py +28 -5
  10. app/projects/schemas/approval.py +5 -0
  11. app/projects/schemas/collaboration.py +0 -1
  12. app/projects/services/approval_service.py +2 -2
  13. app/projects/services/collaboration_service.py +5 -3
  14. app/security/schemas.py +0 -1
  15. core/__init__.py +5 -0
  16. models/__init__.py +3 -0
  17. operations/__init__.py +14 -0
  18. services/__init__.py +11 -0
  19. services/cleanup.py +1 -0
  20. services/downloader.py +1 -0
  21. services/ffmpeg_service.py +1 -0
  22. services/ffprobe_service.py +1 -0
  23. services/input_resolver.py +1 -0
  24. services/media_service.py +1 -0
  25. services/validator.py +1 -0
  26. services/whisper_service.py +1 -0
  27. services/ytdlp_service.py +1 -0
  28. tests/conftest.py +24 -0
  29. tests/test_ai_copilot.py +117 -0
  30. tests/test_analytics_phase10_static.py +42 -0
  31. tests/test_api_contract_regression.py +100 -0
  32. tests/test_authentication.py +307 -0
  33. tests/test_brand_kits.py +42 -0
  34. tests/test_cleanup_worker.py +46 -0
  35. tests/test_collaboration.py +33 -0
  36. tests/test_collaboration_full.py +40 -0
  37. tests/test_content_studio_phase2.py +295 -0
  38. tests/test_cors.py +62 -0
  39. tests/test_database_migration_contracts.py +47 -0
  40. tests/test_database_url.py +16 -0
  41. tests/test_downloader.py +20 -0
  42. tests/test_error_handling.py +27 -0
  43. tests/test_ffmpeg_operations.py +65 -0
  44. tests/test_ffprobe.py +21 -0
  45. tests/test_generation_flux.py +324 -0
  46. tests/test_generation_foundation.py +509 -0
  47. tests/test_generation_provider_runtime.py +371 -0
  48. tests/test_generation_wan.py +346 -0
  49. tests/test_health.py +13 -0
  50. tests/test_input_resolver.py +103 -0
README.md CHANGED
@@ -84,6 +84,21 @@ media-api/
84
 
85
  The top-level `api/`, `services/`, `operations/`, `workers/`, `core/`, and `models/` packages mirror the canonical `app/` modules as import-compatible entry points for integrations that use the requested layout. Runtime composition uses the single implementation under `app/`, so business logic is not duplicated.
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  ## Generation foundation
88
 
89
  MediaRouter includes a tenant-scoped generation request/job foundation at
 
84
 
85
  The top-level `api/`, `services/`, `operations/`, `workers/`, `core/`, and `models/` packages mirror the canonical `app/` modules as import-compatible entry points for integrations that use the requested layout. Runtime composition uses the single implementation under `app/`, so business logic is not duplicated.
86
 
87
+ ## Current implementation surface
88
+
89
+ The current source tree exposes the following product areas. Missing docs or backend modules are recorded as omitted from this working tree, not as unimplemented capabilities elsewhere.
90
+
91
+ - Brand Kit: `/v1/brand`, frontend brand-kit module, Python/TypeScript SDK resources, MCP brand tools, n8n MediaBrandKit node.
92
+ - Projects and collaboration: `/v1/projects`, editor persistence/render jobs, teams/members/invitations, approvals/review comments, notification preferences, Python/TypeScript SDK project resources, MCP collaboration tools, n8n MediaCollaboration node.
93
+ - Publishing and social automation: `/v1/social`, unified publishing, scheduling, retries, reconciliation, analytics sync, Python/TypeScript SDK social resources, MCP social tools, n8n MediaSocial node.
94
+ - Analytics: `/v1/analytics`, overview/timeseries/platform/post analytics, sync runs/cancellation, frontend analytics module, MCP analytics tools, SDK analytics resource. See [`docs/analytics.md`](docs/analytics.md) for implementation and runtime status.
95
+ - AI Studio / Copilot: `/v1/ai`, `/v1/copilot`, durable jobs, deterministic planner, frontend AI/Copilot modules, SDK resources, MCP tools. See [`docs/ai-copilot.md`](docs/ai-copilot.md) for implementation and runtime status.
96
+ - Templates: `/v1/templates`, `/v1/templates/catalog`, template execution, marketplace API, frontend template/marketplace modules, SDK/n8n/MCP coverage. See [`docs/template-marketplace.md`](docs/template-marketplace.md) for implementation and runtime status.
97
+ - Content Studio: editor state, optimistic revisioning, autosave, render jobs, timeline UX, SDK/n8n integration points. See [`docs/content-studio-foundation.md`](docs/content-studio-foundation.md) and [`docs/content-studio-persistence-rendering.md`](docs/content-studio-persistence-rendering.md) for current implementation surface.
98
+ - Brand Kit: `/v1/brand`, frontend brand-kit module, Python/TypeScript SDK resources, MCP brand tools, and an n8n `MediaBrandKit` package entry. See [`docs/brand-kits.md`](docs/brand-kits.md) for implementation and runtime status.
99
+
100
+ Provider certification, PostgreSQL/RLS runtime, Docker, and Hugging Face runtime verification remain deferred until the planned product phases are complete.
101
+
102
  ## Generation foundation
103
 
104
  MediaRouter includes a tenant-scoped generation request/job foundation at
api/__init__.py CHANGED
@@ -1 +1,14 @@
1
  """Compatibility exports for the canonical :mod:`app.api` package."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Compatibility exports for the canonical :mod:`app.api` package."""
2
+
3
+ from app.api.api_keys import * # noqa
4
+ from app.api.audio import * # noqa
5
+ from app.api.generation import * # noqa
6
+ from app.api.health import * # noqa
7
+ from app.api.image import * # noqa
8
+ from app.api.media import * # noqa
9
+ from app.api.probe import * # noqa
10
+ from app.api.social import * # noqa
11
+ from app.api.templates import * # noqa
12
+ from app.api.video import * # noqa
13
+ from app.api.whisper import * # noqa
14
+ from app.api.ytdlp import * # noqa
app/api/api_keys.py CHANGED
@@ -40,7 +40,6 @@ async def current_auth_context(request: Request) -> AuthContextView:
40
  expires_at=context.expires_at,
41
  workspace_id=context.workspace_id,
42
  user_id=context.user_id,
43
- membership_role=context.membership_role,
44
  )
45
 
46
 
 
40
  expires_at=context.expires_at,
41
  workspace_id=context.workspace_id,
42
  user_id=context.user_id,
 
43
  )
44
 
45
 
app/mcp/server.py CHANGED
@@ -21,6 +21,7 @@ from app.mcp.tools.analytics import register_analytics_tools
21
  from app.mcp.tools.audio import register_audio_tools
22
  from app.mcp.tools.brand import register_brand_tools
23
  from app.mcp.tools.image import register_image_tools
 
24
  from app.mcp.tools.probe import register_probe_tools
25
  from app.mcp.tools.social import register_social_tools
26
  from app.mcp.tools.system import register_system_tools
@@ -62,6 +63,7 @@ def create_mcp_server(container: Container) -> FastMCP[Any]:
62
  register_template_tools(server, registry)
63
  register_social_tools(server, registry)
64
  register_brand_tools(server, registry)
 
65
  register_ai_tools(server, registry)
66
  register_analytics_tools(server, registry)
67
  register_resources(server, registry)
 
21
  from app.mcp.tools.audio import register_audio_tools
22
  from app.mcp.tools.brand import register_brand_tools
23
  from app.mcp.tools.image import register_image_tools
24
+ from app.mcp.tools.collaboration import register_collaboration_tools
25
  from app.mcp.tools.probe import register_probe_tools
26
  from app.mcp.tools.social import register_social_tools
27
  from app.mcp.tools.system import register_system_tools
 
63
  register_template_tools(server, registry)
64
  register_social_tools(server, registry)
65
  register_brand_tools(server, registry)
66
+ register_collaboration_tools(server, registry)
67
  register_ai_tools(server, registry)
68
  register_analytics_tools(server, registry)
69
  register_resources(server, registry)
app/mcp/tools/collaboration.py CHANGED
@@ -1,25 +1,35 @@
1
  from __future__ import annotations
2
  from typing import Any
3
- from app.mcp.registry import register_tool
 
 
4
  from app.projects.services.collaboration_service import CollaborationService
5
  from app.security.context import auth_context
6
 
7
- @register_tool("collaboration.list_teams")
8
- async def list_teams(ctx: Any) -> list[dict[str, Any]]:
9
- """List all teams in the workspace."""
10
- auth = auth_context.get()
11
- if not auth or not auth.workspace_id:
12
- raise Exception("Unauthorized")
13
-
14
- service: CollaborationService = ctx.container.collaboration
15
- return await service.list_teams(auth.workspace_id)
16
-
17
- @register_tool("collaboration.create_team")
18
- async def create_team(ctx: Any, name: str) -> dict[str, Any]:
19
- """Create a new team in the workspace."""
20
- auth = auth_context.get()
21
- if not auth or not auth.workspace_id:
22
- raise Exception("Unauthorized")
23
-
24
- service: CollaborationService = ctx.container.collaboration
25
- return (await service.create_team(auth.workspace_id, name)).model_dump()
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
  from typing import Any
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+ from app.mcp.registry import MCPRegistry
6
  from app.projects.services.collaboration_service import CollaborationService
7
  from app.security.context import auth_context
8
 
9
+
10
+ def register_collaboration_tools(server: FastMCP[Any], registry: MCPRegistry) -> None:
11
+ @server.tool(description="List teams in the current workspace.")
12
+ async def list_teams() -> dict[str, Any]:
13
+ auth = auth_context.get()
14
+ if not auth or not auth.workspace_id:
15
+ raise ValueError("Unauthorized")
16
+
17
+ async def action() -> list[dict[str, Any]]:
18
+ service: CollaborationService = registry.container.collaboration
19
+ return [item.model_dump(mode="json") for item in await service.list_teams(auth.workspace_id)]
20
+
21
+ return await registry.run_metadata_tool("collaboration.list_teams", action)
22
+
23
+ @server.tool(description="Create a team in the current workspace.")
24
+ async def create_team(name: str) -> dict[str, Any]:
25
+ auth = auth_context.get()
26
+ if not auth or not auth.workspace_id:
27
+ raise ValueError("Unauthorized")
28
+
29
+ async def action() -> dict[str, Any]:
30
+ service: CollaborationService = registry.container.collaboration
31
+ return (await service.create_team(auth.workspace_id, name)).model_dump(mode="json")
32
+
33
+ return await registry.run_metadata_tool("collaboration.create_team", action)
34
+
35
+ register_collaboration_tools # re-export registration symbol for compatibility
app/projects/api.py CHANGED
@@ -32,7 +32,7 @@ from app.projects.schemas.collaboration import (
32
  TeamBase,
33
  MemberResponse
34
  )
35
- from app.projects.schemas.approval import ApprovalRequest, ReviewComment
36
  from app.security.errors import ForbiddenError
37
 
38
  router = APIRouter(prefix="/v1/projects", tags=["projects"])
@@ -82,7 +82,24 @@ async def archive_team(request: Request, team_id: str) -> Response:
82
 
83
  @router.get("/workspace/workflows/{workflow_id}/requests", response_model=list[ApprovalRequest])
84
  async def list_approval_requests(request: Request, workflow_id: str) -> list[ApprovalRequest]:
85
- return await request.app.state.container.approval.list_requests(workflow_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
 
88
  @router.post("/workspace/requests/{request_id}/approve", response_model=ApprovalRequest)
@@ -90,16 +107,16 @@ async def approve_request(
90
  request: Request,
91
  request_id: str
92
  ) -> ApprovalRequest:
93
- _, user_id, _, _ = _identity(request)
94
- return await request.app.state.container.approval.approve_request(request_id, user_id)
95
 
96
  @router.post("/workspace/requests/{request_id}/reject", response_model=ApprovalRequest)
97
  async def reject_request(
98
  request: Request,
99
  request_id: str
100
  ) -> ApprovalRequest:
101
- _, user_id, _, _ = _identity(request)
102
- return await request.app.state.container.approval.reject_request(request_id, user_id)
103
 
104
  @router.post("/workspace/requests/{request_id}/comments", response_model=ReviewComment)
105
  async def add_review_comment(
@@ -309,7 +326,10 @@ async def list_project_collaborators(
309
  request: Request,
310
  project_id: UUID
311
  ) -> list[MemberResponse]:
312
- return await request.app.state.container.collaboration.list_project_collaborators(str(project_id))
 
 
 
313
 
314
  @router.post("/{project_id}/collaborators", response_model=MemberResponse)
315
  async def add_project_collaborator(
@@ -329,7 +349,10 @@ async def remove_project_collaborator(
329
  project_id: UUID,
330
  user_id: str
331
  ) -> Response:
332
- await request.app.state.container.collaboration.remove_project_collaborator(str(project_id), user_id)
 
 
 
333
  return Response(status_code=status.HTTP_204_NO_CONTENT)
334
 
335
 
 
32
  TeamBase,
33
  MemberResponse
34
  )
35
+ from app.projects.schemas.approval import ApprovalRequest, ApprovalRequestCreate, ReviewComment
36
  from app.security.errors import ForbiddenError
37
 
38
  router = APIRouter(prefix="/v1/projects", tags=["projects"])
 
82
 
83
  @router.get("/workspace/workflows/{workflow_id}/requests", response_model=list[ApprovalRequest])
84
  async def list_approval_requests(request: Request, workflow_id: str) -> list[ApprovalRequest]:
85
+ workspace_id, _, _, _ = _identity(request)
86
+ return await request.app.state.container.approval.list_requests(workspace_id, workflow_id)
87
+
88
+
89
+
90
+ @router.post("/workspace/workflows/{workflow_id}/requests", response_model=ApprovalRequest, status_code=status.HTTP_201_CREATED)
91
+ async def create_approval_request(
92
+ request: Request,
93
+ workflow_id: str,
94
+ payload: ApprovalRequestCreate,
95
+ ) -> ApprovalRequest:
96
+ workspace_id, user_id, _, _ = _identity(request)
97
+ return await request.app.state.container.approval.create_request(
98
+ workspace_id=workspace_id,
99
+ workflow_id=workflow_id,
100
+ project_id=payload.project_id,
101
+ user_id=user_id,
102
+ )
103
 
104
 
105
  @router.post("/workspace/requests/{request_id}/approve", response_model=ApprovalRequest)
 
107
  request: Request,
108
  request_id: str
109
  ) -> ApprovalRequest:
110
+ workspace_id, user_id, _, _ = _identity(request)
111
+ return await request.app.state.container.approval.approve_request(workspace_id, request_id, user_id)
112
 
113
  @router.post("/workspace/requests/{request_id}/reject", response_model=ApprovalRequest)
114
  async def reject_request(
115
  request: Request,
116
  request_id: str
117
  ) -> ApprovalRequest:
118
+ workspace_id, user_id, _, _ = _identity(request)
119
+ return await request.app.state.container.approval.reject_request(workspace_id, request_id, user_id)
120
 
121
  @router.post("/workspace/requests/{request_id}/comments", response_model=ReviewComment)
122
  async def add_review_comment(
 
326
  request: Request,
327
  project_id: UUID
328
  ) -> list[MemberResponse]:
329
+ workspace_id, user_id, _, _ = _identity(request)
330
+ return await request.app.state.container.collaboration.list_project_collaborators(
331
+ workspace_id, str(project_id), user_id=user_id
332
+ )
333
 
334
  @router.post("/{project_id}/collaborators", response_model=MemberResponse)
335
  async def add_project_collaborator(
 
349
  project_id: UUID,
350
  user_id: str
351
  ) -> Response:
352
+ workspace_id, _, _, _ = _identity(request)
353
+ await request.app.state.container.collaboration.remove_project_collaborator(
354
+ workspace_id, str(project_id), user_id
355
+ )
356
  return Response(status_code=status.HTTP_204_NO_CONTENT)
357
 
358
 
app/projects/migrations/0014_approval_workspace_integrity.sql ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Additive approval workspace integrity migration.
2
+ --
3
+ -- This migration brings the existing approval domain in line with the
4
+ -- authoritative ORM models and closes the confirmed cross-workspace
5
+ -- authorization gap by backfilling approval requests with their owning
6
+ -- workflow workspace, enforcing the relationship, and adding an index
7
+ -- used by authorization checks.
8
+ --
9
+ -- Apply after 0013_notification_preferences.sql.
10
+
11
+ begin;
12
+
13
+ alter table approval_requests
14
+ add column if not exists workspace_id text;
15
+
16
+ do $$
17
+ begin
18
+ if exists (
19
+ select 1
20
+ from approval_requests
21
+ where workspace_id is null
22
+ ) then
23
+ update approval_requests
24
+ set workspace_id = approval_workflows.workspace_id
25
+ from approval_workflows
26
+ where approval_workflows.id = approval_requests.workflow_id
27
+ and approval_requests.workspace_id is null;
28
+ end if;
29
+ end $$;
30
+
31
+ alter table approval_requests
32
+ alter column workspace_id set not null;
33
+
34
+ alter table approval_requests
35
+ drop constraint if exists approval_requests_workflow_id_fkey,
36
+ add constraint approval_requests_workflow_id_fkey
37
+ foreign key (workflow_id) references approval_workflows(id) on delete cascade;
38
+
39
+ create index if not exists ix_approval_requests_workspace
40
+ on approval_requests(workspace_id);
41
+
42
+ commit;
app/projects/repositories/approval_repository.py CHANGED
@@ -30,9 +30,14 @@ class ApprovalRepository:
30
  await session.refresh(request)
31
  return request
32
 
33
- async def update_request_status(self, request_id: str, status: str) -> ApprovalRequest:
34
  async with self.database.session() as session:
35
- request = await session.get(ApprovalRequest, request_id)
 
 
 
 
 
36
  if not request:
37
  raise Exception("Request not found")
38
  request.status = status
@@ -40,9 +45,14 @@ class ApprovalRepository:
40
  await session.refresh(request)
41
  return request
42
 
43
- async def get_request(self, request_id: str) -> ApprovalRequest:
44
  async with self.database.session() as session:
45
- request = await session.get(ApprovalRequest, request_id)
 
 
 
 
 
46
  if not request:
47
  raise Exception("Request not found")
48
  return request
@@ -60,10 +70,12 @@ class ApprovalRepository:
60
  await session.refresh(comment)
61
  return comment
62
 
63
- async def list_requests(self, workflow_id: str) -> list[ApprovalRequest]:
64
  async with self.database.session() as session:
65
  result = await session.scalars(
66
- select(ApprovalRequest).where(ApprovalRequest.workflow_id == workflow_id)
 
 
 
67
  )
68
  return list(result.all())
69
-
 
30
  await session.refresh(request)
31
  return request
32
 
33
+ async def update_request_status(self, request_id: str, workspace_id: str, status: str) -> ApprovalRequest:
34
  async with self.database.session() as session:
35
+ request = await session.scalar(
36
+ select(ApprovalRequest).where(
37
+ ApprovalRequest.id == request_id,
38
+ ApprovalRequest.workspace_id == workspace_id,
39
+ )
40
+ )
41
  if not request:
42
  raise Exception("Request not found")
43
  request.status = status
 
45
  await session.refresh(request)
46
  return request
47
 
48
+ async def get_request(self, request_id: str, workspace_id: str) -> ApprovalRequest:
49
  async with self.database.session() as session:
50
+ request = await session.scalar(
51
+ select(ApprovalRequest).where(
52
+ ApprovalRequest.id == request_id,
53
+ ApprovalRequest.workspace_id == workspace_id,
54
+ )
55
+ )
56
  if not request:
57
  raise Exception("Request not found")
58
  return request
 
70
  await session.refresh(comment)
71
  return comment
72
 
73
+ async def list_requests(self, workspace_id: str, workflow_id: str) -> list[ApprovalRequest]:
74
  async with self.database.session() as session:
75
  result = await session.scalars(
76
+ select(ApprovalRequest).where(
77
+ ApprovalRequest.workflow_id == workflow_id,
78
+ ApprovalRequest.workspace_id == workspace_id,
79
+ )
80
  )
81
  return list(result.all())
 
app/projects/repositories/collaboration_repository.py CHANGED
@@ -149,14 +149,23 @@ class CollaborationRepository:
149
  return list(result.all())
150
 
151
  async def list_project_collaborators(self, project_id: str) -> list[ProjectCollaborator]:
152
- async with self.database.session() as session:
 
 
 
153
  result = await session.scalars(
154
- select(ProjectCollaborator).where(ProjectCollaborator.project_id == project_id)
 
 
 
155
  )
156
  return list(result.all())
157
 
158
  async def add_project_collaborator(self, workspace_id: str, project_id: str, user_id: str, role: str) -> ProjectCollaborator:
159
- async with self.database.session() as session:
 
 
 
160
  collaborator = ProjectCollaborator(
161
  workspace_id=workspace_id,
162
  project_id=project_id,
@@ -168,14 +177,28 @@ class CollaborationRepository:
168
  await session.refresh(collaborator)
169
  return collaborator
170
 
171
- async def remove_project_collaborator(self, project_id: str, user_id: str) -> None:
172
- async with self.database.session() as session:
 
 
 
173
  collaborator = await session.scalar(
174
  select(ProjectCollaborator).where(
175
  ProjectCollaborator.project_id == project_id,
 
176
  ProjectCollaborator.user_id == user_id
177
  )
178
  )
179
  if collaborator:
180
  await session.delete(collaborator)
181
  await session.commit()
 
 
 
 
 
 
 
 
 
 
 
149
  return list(result.all())
150
 
151
  async def list_project_collaborators(self, project_id: str) -> list[ProjectCollaborator]:
152
+ project = await self._project_for_resources(project_id)
153
+ async with self.database.tenant_session(
154
+ workspace_id=project.workspace_id, user_id=None
155
+ ) as session:
156
  result = await session.scalars(
157
+ select(ProjectCollaborator).where(
158
+ ProjectCollaborator.project_id == project_id,
159
+ ProjectCollaborator.workspace_id == project.workspace_id,
160
+ )
161
  )
162
  return list(result.all())
163
 
164
  async def add_project_collaborator(self, workspace_id: str, project_id: str, user_id: str, role: str) -> ProjectCollaborator:
165
+ await self._project_for_resources(project_id)
166
+ async with self.database.tenant_session(
167
+ workspace_id=workspace_id, user_id=user_id
168
+ ) as session:
169
  collaborator = ProjectCollaborator(
170
  workspace_id=workspace_id,
171
  project_id=project_id,
 
177
  await session.refresh(collaborator)
178
  return collaborator
179
 
180
+ async def remove_project_collaborator(self, workspace_id: str, project_id: str, user_id: str) -> None:
181
+ await self._project_for_resources(project_id)
182
+ async with self.database.tenant_session(
183
+ workspace_id=workspace_id, user_id=user_id
184
+ ) as session:
185
  collaborator = await session.scalar(
186
  select(ProjectCollaborator).where(
187
  ProjectCollaborator.project_id == project_id,
188
+ ProjectCollaborator.workspace_id == workspace_id,
189
  ProjectCollaborator.user_id == user_id
190
  )
191
  )
192
  if collaborator:
193
  await session.delete(collaborator)
194
  await session.commit()
195
+
196
+ @staticmethod
197
+ async def _project_for_resources(project_id: str) -> Project:
198
+ async with CollaborationRepository(None).database.session() as session:
199
+ project = await session.scalar(
200
+ select(Project).where(Project.id == project_id)
201
+ )
202
+ if project is None:
203
+ raise Exception("Project not found")
204
+ return project
app/projects/schemas/approval.py CHANGED
@@ -9,6 +9,11 @@ class ApprovalRequest(BaseModel):
9
  created_by: str
10
  created_at: str
11
 
 
 
 
 
 
12
  class ReviewComment(BaseModel):
13
  id: str
14
  request_id: str
 
9
  created_by: str
10
  created_at: str
11
 
12
+
13
+ class ApprovalRequestCreate(BaseModel):
14
+ project_id: str
15
+
16
+
17
  class ReviewComment(BaseModel):
18
  id: str
19
  request_id: str
app/projects/schemas/collaboration.py CHANGED
@@ -20,7 +20,6 @@ class InvitationResponse(InvitationCreate):
20
  status: str
21
  expires_at: str
22
  created_at: str
23
- token: str | None = None
24
 
25
  class TeamBase(BaseModel):
26
  name: str
 
20
  status: str
21
  expires_at: str
22
  created_at: str
 
23
 
24
  class TeamBase(BaseModel):
25
  name: str
app/projects/services/approval_service.py CHANGED
@@ -33,5 +33,5 @@ class ApprovalService:
33
  async def add_comment(self, request_id: str, user_id: str, workspace_id: str, content: str) -> ReviewComment:
34
  return await self.repository.add_review_comment(request_id, user_id, workspace_id, content)
35
 
36
- async def list_requests(self, workflow_id: str) -> list[ApprovalRequest]:
37
- return await self.repository.list_requests(workflow_id)
 
33
  async def add_comment(self, request_id: str, user_id: str, workspace_id: str, content: str) -> ReviewComment:
34
  return await self.repository.add_review_comment(request_id, user_id, workspace_id, content)
35
 
36
+ async def list_requests(self, workspace_id: str, workflow_id: str) -> list[ApprovalRequest]:
37
+ return await self.repository.list_requests(workspace_id, workflow_id)
app/projects/services/collaboration_service.py CHANGED
@@ -50,7 +50,6 @@ class CollaborationService:
50
  expires_at=invitation.expires_at.isoformat(),
51
  created_at=invitation.created_at.isoformat()
52
  )
53
- setattr(response, "token", token)
54
  return response
55
 
56
  async def list_members(self, workspace_id: str) -> list[MemberResponse]:
@@ -107,14 +106,17 @@ class CollaborationService:
107
  async def get_membership(self, workspace_id: str, user_id: str) -> WorkspaceMembership | None:
108
  return await self.repository.get_membership(workspace_id, user_id)
109
 
110
- async def list_project_collaborators(self, project_id: str) -> list[MemberResponse]:
111
- collaborators = await self.repository.list_project_collaborators(project_id)
112
  return [MemberResponse(id=c.id, workspace_id=c.workspace_id, user_id=c.user_id, role=c.role, created_at=c.created_at.isoformat()) for c in collaborators]
113
 
114
  async def add_project_collaborator(self, workspace_id: str, project_id: str, user_id: str, role: str) -> MemberResponse:
115
  collaborator = await self.repository.add_project_collaborator(workspace_id, project_id, user_id, role)
116
  return MemberResponse(id=collaborator.id, workspace_id=collaborator.workspace_id, user_id=collaborator.user_id, role=collaborator.role, created_at=collaborator.created_at.isoformat())
117
 
 
 
 
118
  async def record_activity(self, workspace_id: str, user_id: str, action: str, entity_id: str, entity_type: str, metadata: dict[str, Any]) -> None:
119
  await self.repository.record_activity(workspace_id, user_id, action, entity_id, entity_type, metadata)
120
 
 
50
  expires_at=invitation.expires_at.isoformat(),
51
  created_at=invitation.created_at.isoformat()
52
  )
 
53
  return response
54
 
55
  async def list_members(self, workspace_id: str) -> list[MemberResponse]:
 
106
  async def get_membership(self, workspace_id: str, user_id: str) -> WorkspaceMembership | None:
107
  return await self.repository.get_membership(workspace_id, user_id)
108
 
109
+ async def list_project_collaborators(self, workspace_id: str, project_id: str, *, user_id: str) -> list[MemberResponse]:
110
+ collaborators = await self.repository.list_project_collaborators(workspace_id, project_id)
111
  return [MemberResponse(id=c.id, workspace_id=c.workspace_id, user_id=c.user_id, role=c.role, created_at=c.created_at.isoformat()) for c in collaborators]
112
 
113
  async def add_project_collaborator(self, workspace_id: str, project_id: str, user_id: str, role: str) -> MemberResponse:
114
  collaborator = await self.repository.add_project_collaborator(workspace_id, project_id, user_id, role)
115
  return MemberResponse(id=collaborator.id, workspace_id=collaborator.workspace_id, user_id=collaborator.user_id, role=collaborator.role, created_at=collaborator.created_at.isoformat())
116
 
117
+ async def remove_project_collaborator(self, workspace_id: str, project_id: str, user_id: str) -> None:
118
+ await self.repository.remove_project_collaborator(workspace_id, project_id, user_id)
119
+
120
  async def record_activity(self, workspace_id: str, user_id: str, action: str, entity_id: str, entity_type: str, metadata: dict[str, Any]) -> None:
121
  await self.repository.record_activity(workspace_id, user_id, action, entity_id, entity_type, metadata)
122
 
app/security/schemas.py CHANGED
@@ -133,7 +133,6 @@ class AuthContextView(BaseModel):
133
  expires_at: datetime | None
134
  workspace_id: str | None = None
135
  user_id: str | None = None
136
- membership_role: str | None = None
137
 
138
 
139
  class AuditLogView(BaseModel):
 
133
  expires_at: datetime | None
134
  workspace_id: str | None = None
135
  user_id: str | None = None
 
136
 
137
 
138
  class AuditLogView(BaseModel):
core/__init__.py CHANGED
@@ -1 +1,6 @@
1
  """Compatibility exports for the canonical :mod:`app.core` package."""
 
 
 
 
 
 
1
  """Compatibility exports for the canonical :mod:`app.core` package."""
2
+
3
+ from app.core.config import * # noqa
4
+ from app.core.exceptions import * # noqa
5
+ from app.core.logger import * # noqa
6
+ from app.core.response import * # noqa
models/__init__.py CHANGED
@@ -1 +1,4 @@
1
  """Compatibility exports for the canonical :mod:`app.models` package."""
 
 
 
 
1
  """Compatibility exports for the canonical :mod:`app.models` package."""
2
+
3
+ from app.models.media import * # noqa
4
+ from app.models.requests import * # noqa
operations/__init__.py CHANGED
@@ -1 +1,15 @@
1
  """Compatibility exports for the canonical :mod:`app.operations` package."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Compatibility exports for the canonical :mod:`app.operations` package."""
2
+
3
+ from app.operations.common import * # noqa
4
+ from app.operations.compress import * # noqa
5
+ from app.operations.concat import * # noqa
6
+ from app.operations.convert import * # noqa
7
+ from app.operations.crop import * # noqa
8
+ from app.operations.extract_audio import * # noqa
9
+ from app.operations.merge import * # noqa
10
+ from app.operations.resize import * # noqa
11
+ from app.operations.rotate import * # noqa
12
+ from app.operations.subtitles import * # noqa
13
+ from app.operations.thumbnails import * # noqa
14
+ from app.operations.trim import * # noqa
15
+ from app.operations.watermark import * # noqa
services/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compatibility exports for the canonical :mod:`app.services` package."""
2
+
3
+ from app.services.cleanup import * # noqa
4
+ from app.services.downloader import * # noqa
5
+ from app.services.ffmpeg_service import * # noqa
6
+ from app.services.ffprobe_service import * # noqa
7
+ from app.services.input_resolver import * # noqa
8
+ from app.services.media_service import * # noqa
9
+ from app.services.validator import * # noqa
10
+ from app.services.whisper_service import * # noqa
11
+ from app.services.ytdlp_service import * # noqa
services/cleanup.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from app.services.cleanup import * # noqa
services/downloader.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from app.services.downloader import * # noqa
services/ffmpeg_service.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from app.services.ffmpeg_service import * # noqa
services/ffprobe_service.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from app.services.ffprobe_service import * # noqa
services/input_resolver.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from app.services.input_resolver import * # noqa
services/media_service.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from app.services.media_service import * # noqa
services/validator.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from app.services.validator import * # noqa
services/whisper_service.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from app.services.whisper_service import * # noqa
services/ytdlp_service.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from app.services.ytdlp_service import * # noqa
tests/conftest.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from app.core.config import Settings
8
+
9
+
10
+ @pytest.fixture
11
+ def settings(tmp_path: Path) -> Settings:
12
+ return Settings(
13
+ _env_file=None,
14
+ temp_dir=tmp_path / "temp",
15
+ output_dir=tmp_path / "outputs",
16
+ max_upload_size=10 * 1024 * 1024,
17
+ cleanup_minutes=1,
18
+ cleanup_interval_seconds=3600,
19
+ whisper_model="tiny",
20
+ max_workers=1,
21
+ allow_private_urls=True,
22
+ auth_enabled=False,
23
+ database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
24
+ )
tests/test_ai_copilot.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from uuid import uuid4
5
+
6
+ import pytest
7
+ from pydantic import ValidationError
8
+
9
+ from app.copilot.actions import CopilotActionRegistry
10
+ from app.copilot.errors import CopilotInvalidRequestError
11
+ from app.copilot.planner import CopilotPlanner
12
+ from app.copilot.schemas import (
13
+ CopilotContext,
14
+ CopilotEditorSummary,
15
+ CopilotPlan,
16
+ )
17
+
18
+
19
+ def context(*, capabilities: list[str], asset: bool = False, clip: bool = False):
20
+ project_id = uuid4()
21
+ return CopilotContext(
22
+ workspace_id=str(uuid4()),
23
+ project_id=project_id,
24
+ selected_asset_ids=[uuid4()] if asset else [],
25
+ selected_clip_ids=["clip-1"] if clip else [],
26
+ editor_summary=CopilotEditorSummary(
27
+ revision=4, duration_ms=30_000, track_count=1, clip_count=1
28
+ ),
29
+ available_capabilities=capabilities,
30
+ )
31
+
32
+
33
+ def test_planner_fails_closed_for_unavailable_transcription() -> None:
34
+ plan = CopilotPlanner().plan(
35
+ "Turn this podcast into a TikTok",
36
+ context(capabilities=["editor.render"], asset=True),
37
+ )
38
+ assert not plan.executable
39
+ assert plan.unsupported_capabilities == ["ai.transcribe"]
40
+ assert plan.actions == []
41
+
42
+
43
+ def test_planner_requires_confirmation_for_render_and_generation() -> None:
44
+ render = CopilotPlanner().plan("Render this project", context(capabilities=["editor.render"]))
45
+ assert render.executable and render.requires_confirmation
46
+ assert render.actions[0].type == "editor.render"
47
+ image = CopilotPlanner().plan(
48
+ "Generate an image of a lighthouse",
49
+ context(capabilities=["ai.generate_image"]),
50
+ )
51
+ assert image.executable and image.requires_confirmation
52
+ assert image.actions[0].type == "ai.generate_image"
53
+
54
+
55
+ def test_action_plan_rejects_unknown_model_generated_structures() -> None:
56
+ with pytest.raises(ValidationError):
57
+ CopilotPlan.model_validate(
58
+ {
59
+ "intent": "unsafe",
60
+ "explanation": "unsafe",
61
+ "actions": [
62
+ {
63
+ "id": "a",
64
+ "type": "shell.execute",
65
+ "arguments": {"command": "rm -rf /"},
66
+ "reason": "unsafe",
67
+ "requires_confirmation": False,
68
+ "destructive": False,
69
+ "external_side_effect": False,
70
+ "required_permission": "admin",
71
+ "required_capability": "shell",
72
+ }
73
+ ],
74
+ "missing_information": [],
75
+ "unsupported_capabilities": [],
76
+ "executable": True,
77
+ "requires_confirmation": False,
78
+ }
79
+ )
80
+
81
+
82
+ def test_action_registry_rejects_policy_metadata_tampering() -> None:
83
+ registry = CopilotActionRegistry(
84
+ projects=None, # type: ignore[arg-type]
85
+ assets=None, # type: ignore[arg-type]
86
+ editor=None, # type: ignore[arg-type]
87
+ renders=None, # type: ignore[arg-type]
88
+ ai=None, # type: ignore[arg-type]
89
+ templates=None, # type: ignore[arg-type]
90
+ )
91
+ plan = CopilotPlanner().plan(
92
+ "Generate an image of a lighthouse",
93
+ context(capabilities=["ai.generate_image"]),
94
+ )
95
+ tampered = plan.actions[0].model_copy(update={"requires_confirmation": False})
96
+ with pytest.raises(CopilotInvalidRequestError):
97
+ registry.validate(tampered)
98
+
99
+
100
+ def test_copilot_migration_is_additive_and_tenant_isolated() -> None:
101
+ migration = (
102
+ (Path(__file__).resolve().parents[1] / "app/projects/migrations/0005_ai_copilot.sql")
103
+ .read_text(encoding="utf-8")
104
+ .lower()
105
+ )
106
+ for expected in (
107
+ "create table if not exists copilot_runs",
108
+ "unique (workspace_id, idempotency_key)",
109
+ "enable row level security",
110
+ "force row level security",
111
+ "create policy copilot_runs_select",
112
+ "create policy copilot_runs_insert",
113
+ "create policy copilot_runs_update",
114
+ "copilot run identity fields are immutable",
115
+ ):
116
+ assert expected in migration
117
+ assert "drop table" not in migration
tests/test_analytics_phase10_static.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ ROOT = Path(__file__).resolve().parents[1]
4
+
5
+
6
+ def test_analytics_migration_is_additive_and_forces_rls() -> None:
7
+ text = (ROOT / "app/social/migrations/0010_analytics_insights.sql").read_text()
8
+ normalized = text.lower()
9
+ assert "drop table" not in normalized
10
+ for table in (
11
+ "analytics_sync_runs",
12
+ "analytics_metric_snapshots",
13
+ "analytics_post_metrics",
14
+ "analytics_platform_metrics",
15
+ ):
16
+ assert f"create table if not exists {table}" in normalized
17
+ assert normalized.count("force row level security") >= 1
18
+ assert "current_setting(''app.workspace_id''" in normalized
19
+
20
+
21
+ def test_analytics_routes_and_transports_are_narrow() -> None:
22
+ api = (ROOT / "app/analytics/api.py").read_text()
23
+ mcp = (ROOT / "app/mcp/tools/analytics.py").read_text()
24
+ sdk = (ROOT / "sdk/typescript/src/resources/analytics.ts").read_text()
25
+ for route in (
26
+ '"/overview"',
27
+ '"/timeseries"',
28
+ '"/platforms"',
29
+ '"/posts"',
30
+ '"/sync"',
31
+ ):
32
+ assert route in api
33
+ assert "execute analytics query" not in mcp.lower()
34
+ assert "class AnalyticsResource" in sdk
35
+
36
+
37
+ def test_analytics_never_fabricates_provider_metrics() -> None:
38
+ service = (ROOT / "app/analytics/service.py").read_text()
39
+ provider = (ROOT / "app/social/providers/base.py").read_text()
40
+ assert "self.social_analytics.post" in service
41
+ assert "get_metrics" in provider
42
+ assert "random.randint" not in service
tests/test_api_contract_regression.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import re
5
+ from pathlib import Path
6
+ import unittest
7
+
8
+
9
+ ROUTES = {
10
+ "brand_api": Path("app/brand/api.py"),
11
+ "projects_api": Path("app/projects/api.py"),
12
+ }
13
+
14
+ FRONTEND_CALLS = {
15
+ "brand_api": Path("frontend/features/brand-kits/api/index.ts"),
16
+ "collaboration_api": Path("frontend/features/workspace/collaboration/api/collaboration.ts"),
17
+ }
18
+
19
+ BRAND_ROUTES = {
20
+ "router.post('', response_model=BrandKitResponse, status_code=status.HTTP_201_CREATED)": "/v1/brand POST",
21
+ "router.get('', response_model=list[BrandKitResponse])": "/v1/brand GET",
22
+ "router.patch('/{brand_kit_id}', response_model=BrandKitResponse)": "/v1/brand PATCH",
23
+ "router.delete('/{brand_kit_id}', status_code=status.HTTP_204_NO_CONTENT)": "/v1/brand DELETE",
24
+ }
25
+
26
+ EXPECTED_BRAND_FRONTEND_CALLS = [
27
+ "await apiClient.get('/v1/brand');",
28
+ "await apiClient.post('/v1/brand', payload);",
29
+ ]
30
+
31
+ EXPECTED_COLLABORATION_FRONTEND_CALLS = [
32
+ "await apiClient.get('/v1/projects/workspace/teams');",
33
+ "await apiClient.post('/v1/projects/workspace/teams', payload);",
34
+ "await apiClient.post('/v1/projects/workspace/invitations', payload);",
35
+ "await apiClient.get('/v1/projects/workspace/members');",
36
+ "await apiClient.delete(`/v1/projects/workspace/members/${userId}`);",
37
+ "await apiClient.patch(`/v1/projects/workspace/members/${userId}/role?new_role=${newRole}`);",
38
+ "await apiClient.get(`/v1/projects/workspace/workflows/${workflowId}/requests`);",
39
+ "await apiClient.post(`/v1/projects/workspace/workflows/${workflowId}/requests`, { project_id: projectId });",
40
+ "await apiClient.post(`/v1/projects/workspace/requests/${requestId}/approve`);",
41
+ "await apiClient.post(`/v1/projects/workspace/requests/${requestId}/reject`);",
42
+ "await apiClient.post(`/v1/projects/workspace/requests/${requestId}/comments?content=${encodeURIComponent(content)}`);",
43
+ "await apiClient.get(`/v1/projects/${encodeURIComponent(projectId)}/collaborators`);",
44
+ "await apiClient.post(`/v1/projects/${encodeURIComponent(projectId)}/collaborators?user_id=${encodeURIComponent(userId)}&role=${encodeURIComponent(role)}`);",
45
+ "await apiClient.delete(`/v1/projects/${encodeURIComponent(projectId)}/collaborators/${encodeURIComponent(userId)}`);",
46
+ ]
47
+
48
+
49
+ def _route_decorators(path: Path) -> list[str]:
50
+ tree = ast.parse(path.read_text())
51
+ calls = []
52
+ for node in tree.body:
53
+ if not isinstance(node, ast.AsyncFunctionDef):
54
+ continue
55
+ for decorator in node.decorator_list:
56
+ if isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute) and decorator.func.attr in {"get", "post", "patch", "delete"}:
57
+ calls.append(ast.unparse(decorator))
58
+ return calls
59
+
60
+
61
+ def _frontend_calls(path: Path) -> list[str]:
62
+ return [re.sub(r"^\s*const\s+\{[^}]*\}\s+=\s+", "", line.strip()) for line in path.read_text().splitlines() if "apiClient." in line]
63
+
64
+
65
+ def test_brand_kit_routes_match_expected_contract() -> None:
66
+ assert _route_decorators(ROUTES["brand_api"]) == list(BRAND_ROUTES.keys())
67
+
68
+
69
+ def test_approval_request_create_route_is_exposed() -> None:
70
+ decorators = _route_decorators(ROUTES["projects_api"])
71
+ assert any(
72
+ decorator == "router.post('/workspace/workflows/{workflow_id}/requests', response_model=ApprovalRequest, status_code=status.HTTP_201_CREATED)"
73
+ for decorator in decorators
74
+ )
75
+
76
+
77
+ def test_brand_kit_frontend_uses_expected_backend_routes() -> None:
78
+ assert _frontend_calls(FRONTEND_CALLS["brand_api"]) == EXPECTED_BRAND_FRONTEND_CALLS
79
+
80
+
81
+ def test_collaboration_frontend_uses_expected_backend_routes() -> None:
82
+ assert _frontend_calls(FRONTEND_CALLS["collaboration_api"]) == EXPECTED_COLLABORATION_FRONTEND_CALLS
83
+
84
+
85
+ class ApiContractRegressionTests(unittest.TestCase):
86
+ def test_brand_kit_routes_match_expected_contract(self) -> None:
87
+ test_brand_kit_routes_match_expected_contract()
88
+
89
+ def test_brand_kit_frontend_uses_expected_backend_routes(self) -> None:
90
+ test_brand_kit_frontend_uses_expected_backend_routes()
91
+
92
+ def test_collaboration_frontend_uses_expected_backend_routes(self) -> None:
93
+ test_collaboration_frontend_uses_expected_backend_routes()
94
+
95
+ def test_approval_request_create_route_is_exposed(self) -> None:
96
+ test_approval_request_create_route_is_exposed()
97
+
98
+
99
+ if __name__ == "__main__":
100
+ unittest.main()
tests/test_authentication.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ from datetime import datetime, timedelta, timezone
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from fastapi.testclient import TestClient
10
+ from sqlalchemy import select
11
+
12
+ from app.container import build_container
13
+ from app.core.config import Settings
14
+ from app.mcp.registry import MCPRegistry
15
+ from app.security.context import auth_context
16
+ from app.security.errors import APIKeyConflictError, ForbiddenError, RateLimitError, UnauthorizedError
17
+ from app.security.models import APIKey, AuditLog
18
+ from app.security.schemas import APIKeyCreate
19
+ from app.security.service import APIKeyService
20
+ from main import create_app
21
+
22
+
23
+ def security_settings(tmp_path: Path, **overrides: object) -> Settings:
24
+ values: dict[str, object] = {
25
+ "_env_file": None,
26
+ "temp_dir": tmp_path / "temp",
27
+ "output_dir": tmp_path / "outputs",
28
+ "database_url": f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
29
+ "auth_enabled": True,
30
+ "auth_last_used_update_seconds": 0,
31
+ "cleanup_interval_seconds": 3600,
32
+ "whisper_model": "tiny",
33
+ "max_workers": 1,
34
+ }
35
+ values.update(overrides)
36
+ return Settings(**values)
37
+
38
+
39
+ @pytest.fixture
40
+ async def security_container(tmp_path: Path):
41
+ container = build_container(security_settings(tmp_path))
42
+ await container.security_database.initialize()
43
+ try:
44
+ yield container
45
+ finally:
46
+ await container.security_database.close()
47
+
48
+
49
+ async def create_key(container, **overrides: object) -> tuple[APIKey, str]:
50
+ values: dict[str, object] = {
51
+ "name": "Automation",
52
+ "environment": "test",
53
+ "role": None,
54
+ "scopes": ["templates:read"],
55
+ }
56
+ values.update(overrides)
57
+ return await container.api_keys.create(APIKeyCreate(**values), created_by="tests")
58
+
59
+
60
+ async def test_key_generation_has_256_bits_and_database_never_stores_secret(
61
+ security_container,
62
+ ) -> None:
63
+ record, secret = await create_key(security_container)
64
+
65
+ environment, encoded_secret = secret.split("_", 2)[1:]
66
+ raw_secret = base64.urlsafe_b64decode(encoded_secret + "=")
67
+ assert environment == "test"
68
+ assert len(raw_secret) == 32
69
+ assert record.key_prefix == f"mp_test_{encoded_secret[:8]}"
70
+ assert record.key_hash == hashlib.sha256(secret.encode()).hexdigest()
71
+
72
+ async with security_container.security_database.session() as session:
73
+ stored = await session.get(APIKey, record.id)
74
+ assert stored is not None
75
+ assert secret not in vars(stored).values()
76
+ assert not hasattr(stored, "api_key")
77
+
78
+
79
+ async def test_authentication_rejects_invalid_expired_disabled_and_revoked_keys(
80
+ security_container,
81
+ ) -> None:
82
+ active, active_secret = await create_key(security_container)
83
+ assert (await security_container.api_keys.authenticate(active_secret)).api_key_id == active.id
84
+
85
+ replacement = "A" if active_secret[-1] != "A" else "B"
86
+ with pytest.raises(UnauthorizedError):
87
+ await security_container.api_keys.authenticate(active_secret[:-1] + replacement)
88
+
89
+ _, expired_secret = await create_key(
90
+ security_container,
91
+ name="Expired",
92
+ expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
93
+ )
94
+ with pytest.raises(UnauthorizedError):
95
+ await security_container.api_keys.authenticate(expired_secret)
96
+
97
+ await security_container.api_keys.set_status(active.id, "disabled")
98
+ with pytest.raises(UnauthorizedError):
99
+ await security_container.api_keys.authenticate(active_secret)
100
+ await security_container.api_keys.set_status(active.id, "active")
101
+ assert (await security_container.api_keys.authenticate(active_secret)).api_key_id == active.id
102
+
103
+ await security_container.api_keys.set_status(active.id, "revoked")
104
+ with pytest.raises(UnauthorizedError):
105
+ await security_container.api_keys.authenticate(active_secret)
106
+ with pytest.raises(APIKeyConflictError):
107
+ await security_container.api_keys.set_status(active.id, "disabled")
108
+ with pytest.raises(APIKeyConflictError):
109
+ await security_container.api_keys.set_status(active.id, "active")
110
+
111
+
112
+ async def test_scope_enforcement_and_rotation_grace_period(security_container) -> None:
113
+ old, old_secret = await create_key(security_container)
114
+ context = await security_container.api_keys.authenticate(old_secret)
115
+ security_container.api_keys.authorize(context, "templates:read")
116
+ with pytest.raises(ForbiddenError):
117
+ security_container.api_keys.authorize(context, "operations:execute")
118
+
119
+ replacement, replacement_secret = await security_container.api_keys.rotate(
120
+ old.id, 60, created_by="tests"
121
+ )
122
+ assert replacement.rotated_from_id == old.id
123
+ assert (await security_container.api_keys.authenticate(old_secret)).api_key_id == old.id
124
+ assert (
125
+ await security_container.api_keys.authenticate(replacement_secret)
126
+ ).api_key_id == replacement.id
127
+ with pytest.raises(APIKeyConflictError):
128
+ await security_container.api_keys.set_status(old.id, "disabled")
129
+
130
+ async with security_container.security_database.session() as session:
131
+ rotating = await session.get(APIKey, old.id)
132
+ assert rotating is not None
133
+ rotating.grace_expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
134
+ await session.commit()
135
+ with pytest.raises(UnauthorizedError):
136
+ await security_container.api_keys.authenticate(old_secret)
137
+ assert (await security_container.api_keys.get(old.id)).status == "revoked"
138
+
139
+
140
+ async def test_per_key_request_and_concurrent_job_limits(security_container) -> None:
141
+ _, request_secret = await create_key(
142
+ security_container, name="RPM", requests_per_minute=1
143
+ )
144
+ request_context = await security_container.api_keys.authenticate(request_secret)
145
+ lease = await security_container.rate_limiter.acquire(
146
+ request_context, is_job=False, is_upload=False, uploaded_bytes=0
147
+ )
148
+ await lease.release()
149
+ with pytest.raises(RateLimitError) as rate_error:
150
+ await security_container.rate_limiter.acquire(
151
+ request_context, is_job=False, is_upload=False, uploaded_bytes=0
152
+ )
153
+ assert rate_error.value.retry_after >= 1
154
+
155
+ _, job_secret = await create_key(
156
+ security_container, name="Concurrency", concurrent_jobs=1
157
+ )
158
+ job_context = await security_container.api_keys.authenticate(job_secret)
159
+ running = await security_container.rate_limiter.acquire(
160
+ job_context, is_job=True, is_upload=False, uploaded_bytes=0
161
+ )
162
+ with pytest.raises(RateLimitError):
163
+ await security_container.rate_limiter.acquire(
164
+ job_context, is_job=True, is_upload=False, uploaded_bytes=0
165
+ )
166
+ await running.release()
167
+ next_job = await security_container.rate_limiter.acquire(
168
+ job_context, is_job=True, is_upload=False, uploaded_bytes=0
169
+ )
170
+ await next_job.release()
171
+
172
+
173
+ async def test_stdio_mcp_uses_shared_context_scopes_rate_limits_and_audit(
174
+ security_container,
175
+ ) -> None:
176
+ _, secret = await create_key(
177
+ security_container, name="MCP Reader", scopes=["mcp:read"]
178
+ )
179
+ context = await security_container.api_keys.authenticate(secret)
180
+ registry = MCPRegistry(security_container)
181
+ unauthorized = await registry.run_metadata_tool("system_info", registry.system_info_data)
182
+ token = auth_context.set(context)
183
+ try:
184
+ resource = await registry.safe_resource("version", registry.version_data)
185
+ forbidden = await registry.run_metadata_tool("system_info", registry.system_info_data)
186
+ finally:
187
+ auth_context.reset(token)
188
+
189
+ assert unauthorized["success"] is False
190
+ assert unauthorized["error"]["code"] == "UNAUTHORIZED"
191
+ assert resource["success"] is True
192
+ assert forbidden["success"] is False
193
+ assert forbidden["error"]["code"] == "FORBIDDEN"
194
+ async with security_container.security_database.session() as session:
195
+ logs = list((await session.scalars(select(AuditLog))).all())
196
+ assert {log.endpoint for log in logs} >= {
197
+ "mcp://tools/resource.version",
198
+ "mcp://tools/system_info",
199
+ }
200
+
201
+
202
+ def test_http_middleware_public_and_authentication_contracts(tmp_path: Path) -> None:
203
+ material = APIKeyService.generate_material("test")
204
+ settings = security_settings(
205
+ tmp_path,
206
+ auth_bootstrap_key_hash=material.key_hash,
207
+ auth_bootstrap_key_prefix=material.key_prefix,
208
+ auth_bootstrap_environment="test",
209
+ auth_default_requests_per_minute=1000,
210
+ )
211
+ application = create_app(settings)
212
+ authorization = {"Authorization": f"Bearer {material.api_key}"}
213
+
214
+ with TestClient(application) as client:
215
+ for path in ("/", "/health", "/version", "/docs", "/openapi.json", "/redoc"):
216
+ assert client.get(path).status_code == 200
217
+
218
+ missing = client.get("/v1/auth/context")
219
+ malformed = client.get(
220
+ "/v1/auth/context", headers={"Authorization": "Basic not-a-mediarouter-key"}
221
+ )
222
+ invalid = client.get(
223
+ "/v1/auth/context", headers={"Authorization": "Bearer mp_test_invalid"}
224
+ )
225
+ for response in (missing, malformed, invalid):
226
+ assert response.status_code == 401
227
+ assert response.json() == {
228
+ "error": "Unauthorized",
229
+ "message": "Invalid or expired API key.",
230
+ }
231
+ assert response.headers["www-authenticate"] == "Bearer"
232
+
233
+ mcp_missing = client.post("/mcp/", json={"jsonrpc": "2.0", "id": 1})
234
+ assert mcp_missing.status_code == 401
235
+
236
+ identity = client.get("/v1/auth/context", headers=authorization)
237
+ assert identity.status_code == 200
238
+ assert identity.json()["key_prefix"] == material.key_prefix
239
+ assert "admin" in identity.json()["scopes"]
240
+
241
+ created = client.post(
242
+ "/v1/api-keys",
243
+ headers=authorization,
244
+ json={
245
+ "name": "Template Reader",
246
+ "environment": "test",
247
+ "role": None,
248
+ "scopes": ["templates:read"],
249
+ },
250
+ )
251
+ assert created.status_code == 201
252
+ limited_authorization = {
253
+ "Authorization": f"Bearer {created.json()['api_key']}"
254
+ }
255
+ assert client.get("/v1/auth/context", headers=limited_authorization).status_code == 200
256
+ forbidden = client.get("/v1/health", headers=limited_authorization)
257
+ assert forbidden.status_code == 403
258
+ assert forbidden.json() == {
259
+ "error": "Forbidden",
260
+ "message": "Missing required scope.",
261
+ }
262
+
263
+ mcp_forbidden = client.post(
264
+ "/mcp/",
265
+ headers=limited_authorization,
266
+ json={
267
+ "jsonrpc": "2.0",
268
+ "id": 1,
269
+ "method": "tools/call",
270
+ "params": {"name": "health", "arguments": {}},
271
+ },
272
+ )
273
+ assert mcp_forbidden.status_code == 403
274
+
275
+ audit_logs = client.get("/v1/audit-logs", headers=authorization)
276
+ assert audit_logs.status_code == 200
277
+ entries = audit_logs.json()
278
+ assert any(
279
+ entry["endpoint"] == "/v1/auth/context"
280
+ and entry["api_key_id"] == identity.json()["id"]
281
+ and entry["response_code"] == 200
282
+ for entry in entries
283
+ )
284
+
285
+
286
+ def test_http_rate_limit_returns_retry_after(tmp_path: Path) -> None:
287
+ material = APIKeyService.generate_material("test")
288
+ application = create_app(
289
+ security_settings(
290
+ tmp_path,
291
+ auth_bootstrap_key_hash=material.key_hash,
292
+ auth_bootstrap_key_prefix=material.key_prefix,
293
+ auth_bootstrap_environment="test",
294
+ auth_default_requests_per_minute=1,
295
+ )
296
+ )
297
+ headers = {"Authorization": f"Bearer {material.api_key}"}
298
+ with TestClient(application) as client:
299
+ assert client.get("/v1/auth/context", headers=headers).status_code == 200
300
+ limited = client.get("/v1/auth/context", headers=headers)
301
+
302
+ assert limited.status_code == 429
303
+ assert limited.json() == {
304
+ "error": "Rate limit exceeded",
305
+ "message": "Retry later.",
306
+ }
307
+ assert int(limited.headers["retry-after"]) >= 1
tests/test_brand_kits.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from unittest.mock import AsyncMock, MagicMock
3
+ from app.brand.services.brand_service import BrandKitService
4
+ from app.brand.services.validation_service import BrandKitValidationService
5
+ from app.brand.models.brand import BrandKitVersion
6
+
7
+ @pytest.fixture
8
+ def validation_service():
9
+ return BrandKitValidationService()
10
+
11
+ def test_brand_kit_validation_missing_logo(validation_service):
12
+ version = BrandKitVersion(version_number=1, created_by="user1")
13
+ result = validation_service.validate(version)
14
+ assert not result['valid']
15
+ assert any(issue['field'] == 'logo_asset_id' for issue in result['issues'])
16
+
17
+ def test_brand_kit_validation_valid(validation_service):
18
+ version = BrandKitVersion(version_number=1, created_by="user1", logo_asset_id="asset123")
19
+ result = validation_service.validate(version)
20
+ assert result['valid']
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_brand_kit_service_create():
24
+ mock_repo = AsyncMock()
25
+ mock_assets = AsyncMock()
26
+ mock_audit = AsyncMock()
27
+
28
+ service = BrandKitService(mock_repo, mock_assets, mock_audit)
29
+
30
+ workspace_id = "ws1"
31
+ name = "Test Kit"
32
+ data = {"logo_asset_id": "asset123"}
33
+ user_id = "user1"
34
+
35
+ mock_assets.get_asset.return_value = {"id": "asset123"}
36
+ mock_repo.create.return_value = (MagicMock(id="kit1"), MagicMock(id="ver1"))
37
+
38
+ await service.create_brand_kit(workspace_id, name, data, user_id=user_id)
39
+
40
+ mock_assets.get_asset.assert_called_once()
41
+ mock_repo.create.assert_called_once()
42
+ mock_audit.log_event.assert_called_once()
tests/test_cleanup_worker.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import time
6
+ from uuid import uuid4
7
+
8
+ from app.services.cleanup import CleanupService
9
+ from app.workers.cleanup_worker import CleanupWorker
10
+
11
+
12
+ async def test_cleanup_removes_expired_workspace(settings) -> None:
13
+ service = CleanupService(settings)
14
+ request_id = str(uuid4())
15
+ workspace = await service.create_workspace(request_id)
16
+ await service.complete(request_id)
17
+ old = time.time() - 120
18
+ os.utime(workspace.root, (old, old))
19
+ removed = await service.cleanup_expired()
20
+ assert removed == 1
21
+ assert not workspace.root.exists()
22
+
23
+
24
+ async def test_cleanup_keeps_active_workspace(settings) -> None:
25
+ service = CleanupService(settings)
26
+ workspace = await service.create_workspace(str(uuid4()))
27
+ old = time.time() - 120
28
+ os.utime(workspace.root, (old, old))
29
+ assert await service.cleanup_expired() == 0
30
+ assert workspace.root.exists()
31
+
32
+
33
+ async def test_cleanup_worker_runs_and_stops() -> None:
34
+ class FakeCleanup:
35
+ def __init__(self) -> None:
36
+ self.called = asyncio.Event()
37
+
38
+ async def cleanup_expired(self) -> int:
39
+ self.called.set()
40
+ return 0
41
+
42
+ service = FakeCleanup()
43
+ worker = CleanupWorker(service, interval_seconds=60) # type: ignore[arg-type]
44
+ await worker.start()
45
+ await asyncio.wait_for(service.called.wait(), timeout=1)
46
+ await worker.stop()
tests/test_collaboration.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from app.projects.repositories.collaboration_repository import CollaborationRepository
3
+ from app.projects.services.collaboration_service import CollaborationService
4
+ from app.projects.errors import CollaborationUnauthorizedError
5
+
6
+ @pytest.mark.asyncio
7
+ async def test_collaboration_logic_admin_removal_constraint(db_session):
8
+ # Setup test workspace and admin users
9
+ repo = CollaborationRepository(db_session)
10
+ service = CollaborationService(repo)
11
+
12
+ workspace_id = "test_workspace"
13
+ admin_user_id = "admin_user"
14
+ target_user_id = "member_user"
15
+
16
+ # 1. Mock memberships: 2 admins
17
+ # Use actual DB insert here if needed for true integration test
18
+ # ... setup DB state ...
19
+
20
+ # 2. Test prevention of last admin removal
21
+ with pytest.raises(CollaborationUnauthorizedError, match="Cannot remove the last administrator."):
22
+ await service.remove_member(workspace_id, admin_user_id, target_user_id)
23
+
24
+ @pytest.mark.asyncio
25
+ async def test_collaboration_logic_self_elevation_prevention(db_session):
26
+ repo = CollaborationRepository(db_session)
27
+ service = CollaborationService(repo)
28
+
29
+ workspace_id = "test_workspace"
30
+ actor_user_id = "user_1"
31
+
32
+ with pytest.raises(CollaborationUnauthorizedError, match="Cannot elevate your own privileges."):
33
+ await service.update_member_role(workspace_id, actor_user_id, actor_user_id, "admin")
tests/test_collaboration_full.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from app.projects.repositories.collaboration_repository import CollaborationRepository
3
+ from app.projects.services.collaboration_service import CollaborationService
4
+
5
+ @pytest.mark.asyncio
6
+ async def test_collaboration_team_lifecycle(db_session):
7
+ repo = CollaborationRepository(db_session)
8
+ service = CollaborationService(repo)
9
+
10
+ workspace_id = "test_workspace"
11
+
12
+ # 1. Create Team
13
+ team = await service.create_team(workspace_id, "Engineering")
14
+ assert team.name == "Engineering"
15
+
16
+ # 2. List Teams
17
+ teams = await service.list_teams(workspace_id)
18
+ assert len(teams) >= 1
19
+
20
+ # 3. Update Team
21
+ updated = await service.update_team(workspace_id, team.id, "Product")
22
+ assert updated.name == "Product"
23
+
24
+ # 4. Archive
25
+ await service.archive_team(workspace_id, team.id)
26
+ teams = await service.list_teams(workspace_id)
27
+ assert not any(t.id == team.id for t in teams)
28
+
29
+ @pytest.mark.asyncio
30
+ async def test_collaboration_invitation_lifecycle(db_session):
31
+ repo = CollaborationRepository(db_session)
32
+ service = CollaborationService(repo)
33
+
34
+ workspace_id = "test_workspace"
35
+ email = "test@example.com"
36
+
37
+ # Test invitation
38
+ invitation = await service.invite_member(workspace_id, email, "member")
39
+ assert invitation.email == email
40
+ assert hasattr(invitation, "token") # Check if token is returned
tests/test_content_studio_phase2.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from pathlib import Path
5
+ from uuid import uuid4
6
+
7
+ import pytest
8
+ from sqlalchemy import select
9
+
10
+ from app.container import build_container
11
+ from app.core.config import Settings
12
+ from app.projects.editor_schemas import EditorDocument, EditorSaveRequest, ProjectRenderCreate
13
+ from app.projects.errors import (
14
+ ProjectEditorConflictError,
15
+ ProjectNotFoundError,
16
+ ProjectRenderLimitError,
17
+ )
18
+ from app.projects.schemas import ProjectCreate
19
+ from app.projects.services.render_compiler import compile_render
20
+ from app.security.models import AuditEvent
21
+ from app.security.schemas import APIKeyCreate
22
+
23
+
24
+ def settings(tmp_path: Path) -> Settings:
25
+ return Settings(
26
+ _env_file=None,
27
+ auth_enabled=True,
28
+ database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
29
+ social_database_url=f"sqlite+aiosqlite:///{tmp_path / 'social.db'}",
30
+ social_auto_migrate=True,
31
+ social_worker_enabled=False,
32
+ generation_worker_enabled=False,
33
+ render_worker_enabled=False,
34
+ social_oauth_encryption_key="test-only-encryption-material",
35
+ temp_dir=tmp_path / "temp",
36
+ output_dir=tmp_path / "outputs",
37
+ whisper_model="tiny",
38
+ )
39
+
40
+
41
+ def document(project_id: str, asset_id: str) -> EditorDocument:
42
+ return EditorDocument.model_validate(
43
+ {
44
+ "schemaVersion": 1,
45
+ "projectId": project_id,
46
+ "timeline": {
47
+ "timeUnit": "milliseconds",
48
+ "tracks": [
49
+ {
50
+ "id": "video-1",
51
+ "type": "video",
52
+ "name": "Video 1",
53
+ "order": 0,
54
+ "muted": False,
55
+ "locked": False,
56
+ "visible": True,
57
+ "clips": [
58
+ {
59
+ "id": "clip-1",
60
+ "kind": "media",
61
+ "trackId": "video-1",
62
+ "assetId": asset_id,
63
+ "label": "source.mp4",
64
+ "startMs": 0,
65
+ "durationMs": 1000,
66
+ "sourceStartMs": 0,
67
+ "sourceDurationMs": 1000,
68
+ "mediaType": "video",
69
+ "transform": {
70
+ "x": 0,
71
+ "y": 0,
72
+ "scaleX": 1,
73
+ "scaleY": 1,
74
+ "rotation": 0,
75
+ },
76
+ "volume": 1,
77
+ "opacity": 1,
78
+ "visible": True,
79
+ "metadata": {},
80
+ }
81
+ ],
82
+ }
83
+ ],
84
+ "transitions": [],
85
+ "markers": [],
86
+ },
87
+ "renderSettings": {"format": "mp4", "width": 1280, "height": 720, "frameRate": 30},
88
+ }
89
+ )
90
+
91
+
92
+ async def actor(container, name: str):
93
+ key, secret = await container.api_keys.create(
94
+ APIKeyCreate(
95
+ name=name,
96
+ environment="test",
97
+ role=None,
98
+ scopes=[
99
+ "projects:read",
100
+ "projects:create",
101
+ "projects:update",
102
+ "jobs:create",
103
+ "jobs:cancel",
104
+ ],
105
+ ),
106
+ created_by="tests",
107
+ )
108
+ return key, await container.api_keys.authenticate(secret)
109
+
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_editor_revision_isolation_render_idempotency_and_cancellation(
113
+ tmp_path: Path,
114
+ ) -> None:
115
+ container = build_container(settings(tmp_path))
116
+ await container.security_database.initialize()
117
+ try:
118
+ key_a, actor_a = await actor(container, "A")
119
+ _, actor_b = await actor(container, "B")
120
+ project = await container.projects.create(
121
+ workspace_id=actor_a.workspace_id,
122
+ user_id=actor_a.user_id,
123
+ api_key_id=key_a.id,
124
+ request_id=str(uuid4()),
125
+ payload=ProjectCreate(name="Studio"),
126
+ )
127
+ request_id = str(uuid4())
128
+ output = container.settings.output_dir / request_id
129
+ output.mkdir(parents=True)
130
+ source = output / "source.mp4"
131
+ source.write_bytes(b"test media")
132
+ asset = await container.assets.register_output(
133
+ workspace_id=actor_a.workspace_id,
134
+ user_id=actor_a.user_id,
135
+ request_id=request_id,
136
+ path=source,
137
+ mime_type="video/mp4",
138
+ project_id=project.id,
139
+ )
140
+ editor_document = document(project.id, asset.id)
141
+ saved = await container.editor.save(
142
+ workspace_id=actor_a.workspace_id,
143
+ user_id=actor_a.user_id,
144
+ api_key_id=key_a.id,
145
+ request_id=str(uuid4()),
146
+ project_id=project.id,
147
+ payload=EditorSaveRequest(expected_revision=0, schema_version=1, state=editor_document),
148
+ )
149
+ assert saved.revision == 1
150
+ with pytest.raises(ProjectEditorConflictError):
151
+ await container.editor.save(
152
+ workspace_id=actor_a.workspace_id,
153
+ user_id=actor_a.user_id,
154
+ api_key_id=key_a.id,
155
+ request_id=str(uuid4()),
156
+ project_id=project.id,
157
+ payload=EditorSaveRequest(
158
+ expected_revision=0, schema_version=1, state=editor_document
159
+ ),
160
+ )
161
+ with pytest.raises(ProjectNotFoundError):
162
+ await container.editor.get(
163
+ workspace_id=actor_b.workspace_id,
164
+ user_id=actor_b.user_id,
165
+ project_id=project.id,
166
+ )
167
+ render_payload = ProjectRenderCreate(
168
+ editor_revision=1, output_format="mp4", width=1280, height=720
169
+ )
170
+ first = await container.renders.create(
171
+ workspace_id=actor_a.workspace_id,
172
+ user_id=actor_a.user_id,
173
+ api_key_id=key_a.id,
174
+ request_id=str(uuid4()),
175
+ project_id=project.id,
176
+ payload=render_payload,
177
+ idempotency_key="render-1",
178
+ )
179
+ second = await container.renders.create(
180
+ workspace_id=actor_a.workspace_id,
181
+ user_id=actor_a.user_id,
182
+ api_key_id=key_a.id,
183
+ request_id=str(uuid4()),
184
+ project_id=project.id,
185
+ payload=render_payload,
186
+ idempotency_key="render-1",
187
+ )
188
+ assert first.id == second.id and first.status == "queued"
189
+ with pytest.raises(ProjectRenderLimitError):
190
+ await container.renders.create(
191
+ workspace_id=actor_a.workspace_id,
192
+ user_id=actor_a.user_id,
193
+ api_key_id=key_a.id,
194
+ request_id=str(uuid4()),
195
+ project_id=project.id,
196
+ payload=render_payload,
197
+ idempotency_key="render-2",
198
+ )
199
+ cancelled = await container.renders.cancel(
200
+ workspace_id=actor_a.workspace_id,
201
+ user_id=actor_a.user_id,
202
+ api_key_id=key_a.id,
203
+ request_id=str(uuid4()),
204
+ project_id=project.id,
205
+ render_id=first.id,
206
+ )
207
+ assert cancelled.status == "cancelled"
208
+ repeated = await container.renders.cancel(
209
+ workspace_id=actor_a.workspace_id,
210
+ user_id=actor_a.user_id,
211
+ api_key_id=key_a.id,
212
+ request_id=str(uuid4()),
213
+ project_id=project.id,
214
+ render_id=first.id,
215
+ )
216
+ assert repeated.status == "cancelled"
217
+ async with container.security_database.tenant_session(
218
+ workspace_id=actor_a.workspace_id,
219
+ user_id=actor_a.user_id,
220
+ ) as session:
221
+ cancellation_events = list(
222
+ (
223
+ await session.scalars(
224
+ select(AuditEvent).where(
225
+ AuditEvent.entity_id == first.id,
226
+ AuditEvent.event_type == "project.render_cancelled",
227
+ )
228
+ )
229
+ ).all()
230
+ )
231
+ assert len(cancellation_events) == 1
232
+ finally:
233
+ await container.security_database.close()
234
+
235
+
236
+ def test_render_compiler_is_deterministic_and_uses_server_paths(tmp_path: Path) -> None:
237
+ source = tmp_path / "source.mp4"
238
+ source.write_bytes(b"media")
239
+ state = document(str(uuid4()), str(uuid4()))
240
+ asset_id = next(iter(state.asset_ids()))
241
+ first = compile_render(
242
+ state,
243
+ asset_paths={asset_id: (source, "video/mp4")},
244
+ width=1280,
245
+ height=720,
246
+ frame_rate=30,
247
+ output_format="mp4",
248
+ quality="standard",
249
+ preset="balanced",
250
+ )
251
+ second = compile_render(
252
+ state,
253
+ asset_paths={asset_id: (source, "video/mp4")},
254
+ width=1280,
255
+ height=720,
256
+ frame_rate=30,
257
+ output_format="mp4",
258
+ quality="standard",
259
+ preset="balanced",
260
+ )
261
+ assert first == second
262
+ assert source in first.args
263
+ assert first.duration_ms == 1000
264
+ assert "yuv420p" in first.args
265
+
266
+
267
+ def test_render_compiler_ignores_hidden_timeline_tail(tmp_path: Path) -> None:
268
+ source = tmp_path / "source.mp4"
269
+ source.write_bytes(b"media")
270
+ state = document(str(uuid4()), str(uuid4()))
271
+ payload = state.model_dump(by_alias=True)
272
+ hidden_track = deepcopy(payload["timeline"]["tracks"][0])
273
+ hidden_track.update({"id": "video-hidden", "name": "Hidden", "order": 1, "visible": False})
274
+ hidden_track["clips"][0].update(
275
+ {"id": "clip-hidden", "trackId": "video-hidden", "startMs": 120_000}
276
+ )
277
+ payload["timeline"]["tracks"].append(hidden_track)
278
+ state_with_hidden_tail = EditorDocument.model_validate(payload)
279
+ asset_id = next(iter(state_with_hidden_tail.asset_ids()))
280
+
281
+ plan = compile_render(
282
+ state_with_hidden_tail,
283
+ asset_paths={asset_id: (source, "video/mp4")},
284
+ width=1280,
285
+ height=720,
286
+ frame_rate=30,
287
+ output_format="webm",
288
+ quality="high",
289
+ preset="quality",
290
+ )
291
+
292
+ assert plan.duration_ms == 1000
293
+ assert plan.args.count(source) == 1
294
+ assert "18" in plan.args
295
+ assert "0" in plan.args
tests/test_cors.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import httpx
6
+
7
+ from app.core.config import Settings
8
+ from main import create_app
9
+
10
+
11
+ async def test_configured_frontend_origin_receives_cors_headers(tmp_path: Path) -> None:
12
+ settings = Settings(
13
+ _env_file=None,
14
+ auth_enabled=False,
15
+ database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
16
+ temp_dir=tmp_path / "temp",
17
+ output_dir=tmp_path / "outputs",
18
+ cors_allowed_origins="https://workspace.example.vercel.app",
19
+ )
20
+ app = create_app(settings)
21
+ transport = httpx.ASGITransport(app=app)
22
+
23
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
24
+ response = await client.options(
25
+ "/v1/projects",
26
+ headers={
27
+ "Origin": "https://workspace.example.vercel.app",
28
+ "Access-Control-Request-Method": "GET",
29
+ "Access-Control-Request-Headers": "Authorization",
30
+ },
31
+ )
32
+
33
+ assert response.status_code == 200
34
+ assert response.headers["access-control-allow-origin"] == (
35
+ "https://workspace.example.vercel.app"
36
+ )
37
+ assert "authorization" in response.headers["access-control-allow-headers"].lower()
38
+
39
+
40
+ async def test_unconfigured_origin_receives_no_cors_authorization(tmp_path: Path) -> None:
41
+ settings = Settings(
42
+ _env_file=None,
43
+ auth_enabled=False,
44
+ database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
45
+ temp_dir=tmp_path / "temp",
46
+ output_dir=tmp_path / "outputs",
47
+ cors_allowed_origins="https://workspace.example.vercel.app",
48
+ )
49
+ app = create_app(settings)
50
+ transport = httpx.ASGITransport(app=app)
51
+
52
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
53
+ response = await client.options(
54
+ "/v1/projects",
55
+ headers={
56
+ "Origin": "https://attacker.example",
57
+ "Access-Control-Request-Method": "GET",
58
+ },
59
+ )
60
+
61
+ assert response.status_code == 400
62
+ assert "access-control-allow-origin" not in response.headers
tests/test_database_migration_contracts.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+
8
+ MIGRATION_FILES = sorted(Path("app/projects/migrations").glob("*.sql"))
9
+ ALLOWED_TABLES = {
10
+ "teams",
11
+ "team_members",
12
+ "invitations",
13
+ "project_collaborators",
14
+ "approval_workflows",
15
+ "approval_requests",
16
+ "review_comments",
17
+ "collaboration_activity",
18
+ "notification_preferences",
19
+ }
20
+
21
+
22
+ class MigrationContractTests(unittest.TestCase):
23
+ def test_latest_migration_adds_approval_request_workspace_integrity(self) -> None:
24
+ latest = MIGRATION_FILES[-1].read_text()
25
+
26
+ self.assertIn("alter table approval_requests", latest)
27
+ self.assertIn("add column if not exists workspace_id text", latest)
28
+ self.assertIn("alter column workspace_id set not null", latest)
29
+ self.assertIn("create index if not exists ix_approval_requests_workspace", latest)
30
+
31
+ def test_migrations_do_not_recreate_shared_tables(self) -> None:
32
+ counts: dict[str, int] = {}
33
+ for path in MIGRATION_FILES:
34
+ body = path.read_text()
35
+ for table in ALLOWED_TABLES:
36
+ counts[table] = counts.get(table, 0) + body.count(f"create table if not exists {table}")
37
+
38
+ for table, count in counts.items():
39
+ self.assertEqual(
40
+ count,
41
+ 1,
42
+ f"Duplicate table creation detected for {table}: {count} migrations recreate it",
43
+ )
44
+
45
+
46
+ if __name__ == "__main__":
47
+ unittest.main()
tests/test_database_url.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.core.database_url import normalize_async_database_url
2
+
3
+
4
+ def test_bare_postgres_urls_use_the_installed_async_driver() -> None:
5
+ assert (
6
+ normalize_async_database_url("postgresql://user:secret@db.example/app")
7
+ == "postgresql+asyncpg://user:secret@db.example/app"
8
+ )
9
+ assert (
10
+ normalize_async_database_url("postgres://user:secret@db.example/app")
11
+ == "postgresql+asyncpg://user:secret@db.example/app"
12
+ )
13
+ assert (
14
+ normalize_async_database_url("postgresql+asyncpg://user:secret@db.example/app")
15
+ == "postgresql+asyncpg://user:secret@db.example/app"
16
+ )
tests/test_downloader.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from unittest.mock import AsyncMock
2
+
3
+ import respx
4
+ from httpx import Response
5
+
6
+ from app.services.downloader import Downloader
7
+ from app.services.validator import MediaValidator
8
+
9
+
10
+ @respx.mock
11
+ async def test_url_download_streams_to_disk(settings, tmp_path) -> None:
12
+ url = "https://media.example.test/sample.mp3"
13
+ respx.get(url).mock(
14
+ return_value=Response(200, content=b"ID3data", headers={"content-type": "audio/mpeg"})
15
+ )
16
+ downloader = Downloader(settings, MediaValidator(settings))
17
+ downloader.validate_url = AsyncMock() # type: ignore[method-assign]
18
+ path, mime_type = await downloader.download(url, tmp_path)
19
+ assert path.read_bytes() == b"ID3data"
20
+ assert mime_type == "audio/mpeg"
tests/test_error_handling.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from fastapi.testclient import TestClient
3
+
4
+ from app.core.exceptions import NotFoundError
5
+ from main import create_app
6
+
7
+
8
+ def test_errors_use_safe_standard_envelope(settings) -> None:
9
+ with TestClient(create_app(settings), raise_server_exceptions=False) as client:
10
+ response = client.post(
11
+ "/v1/probe",
12
+ json={"base64": "not-valid-base64!", "filename": "sample.mp3"},
13
+ )
14
+ assert response.status_code == 422
15
+ payload = response.json()
16
+ assert payload["success"] is False
17
+ assert payload["request_id"]
18
+ assert payload["error"]["code"] == "INVALID_INPUT"
19
+ assert "traceback" not in response.text.lower()
20
+
21
+
22
+ def test_download_path_traversal_is_rejected(settings) -> None:
23
+ app = create_app(settings)
24
+ with pytest.raises(NotFoundError):
25
+ app.state.container.cleanup.resolve_download(
26
+ "00000000-0000-0000-0000-000000000000", "../secret.mp4"
27
+ )
tests/test_ffmpeg_operations.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+
6
+ import pytest
7
+
8
+ from app.models.media import InputMedia, MediaSource
9
+ from app.operations.convert import convert_audio
10
+ from app.services.ffmpeg_service import FFmpegService
11
+
12
+
13
+ @pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg is not installed")
14
+ async def test_ffmpeg_audio_conversion(settings, tmp_path) -> None:
15
+ source = tmp_path / "tone.wav"
16
+ subprocess.run(
17
+ [
18
+ "ffmpeg",
19
+ "-hide_banner",
20
+ "-loglevel",
21
+ "error",
22
+ "-f",
23
+ "lavfi",
24
+ "-i",
25
+ "sine=frequency=440:duration=0.2",
26
+ "-y",
27
+ str(source),
28
+ ],
29
+ check=True,
30
+ )
31
+ media = InputMedia(
32
+ source=MediaSource.MULTIPART,
33
+ filename=source.name,
34
+ mime_type="audio/wav",
35
+ temp_path=source,
36
+ size=source.stat().st_size,
37
+ )
38
+ result = await convert_audio(
39
+ FFmpegService(settings), [media], {"format": "mp3"}, tmp_path / "out"
40
+ )
41
+ assert result.path is not None
42
+ assert result.path.is_file()
43
+ assert result.path.stat().st_size > 0
44
+
45
+
46
+ async def test_ffmpeg_codec_listing_is_structured(settings, monkeypatch) -> None:
47
+ service = FFmpegService(settings)
48
+
49
+ async def fake_capture(*args, **kwargs) -> str:
50
+ return """Codecs:
51
+ D..... = Decoding supported
52
+ .E.... = Encoding supported
53
+ -------
54
+ DEV.LS h264 H.264 / AVC / MPEG-4 AVC
55
+ DEA.L. aac AAC (Advanced Audio Coding)
56
+ """
57
+
58
+ monkeypatch.setattr(service, "_capture", fake_capture)
59
+
60
+ codecs = await service.codecs()
61
+
62
+ assert [codec["name"] for codec in codecs] == ["h264", "aac"]
63
+ assert codecs[0]["decode"] is True
64
+ assert codecs[0]["encode"] is True
65
+ assert codecs[0]["type"] == "video"
tests/test_ffprobe.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import wave
5
+
6
+ import pytest
7
+
8
+ from app.services.ffprobe_service import FFprobeService
9
+
10
+
11
+ @pytest.mark.skipif(shutil.which("ffprobe") is None, reason="ffprobe is not installed")
12
+ async def test_ffprobe_returns_audio_metadata(settings, tmp_path) -> None:
13
+ audio = tmp_path / "tone.wav"
14
+ with wave.open(str(audio), "wb") as stream:
15
+ stream.setnchannels(1)
16
+ stream.setsampwidth(2)
17
+ stream.setframerate(8000)
18
+ stream.writeframes(b"\x00\x00" * 8000)
19
+ metadata = await FFprobeService(settings).probe(audio)
20
+ assert metadata["duration"] == pytest.approx(1.0, abs=0.01)
21
+ assert metadata["audio_streams"][0]["codec"] == "pcm_s16le"
tests/test_generation_flux.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mocked protocol tests for the audited FLUX.2 Klein worker integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ from app.core.config import Settings
13
+ from app.generation.domain.enums import (
14
+ GenerationModality,
15
+ WorkerCancellationStatus,
16
+ WorkerErrorCategory,
17
+ WorkerJobStatus,
18
+ )
19
+ from app.generation.domain.errors import (
20
+ GenerationCapabilityUnsupportedError,
21
+ GenerationValidationError,
22
+ GenerationWorkerError,
23
+ )
24
+ from app.generation.domain.retry import GenerationRetryPolicy
25
+ from app.generation.model_registry import GenerationModelRegistration, GenerationModelRegistry
26
+ from app.generation.providers.flux import (
27
+ FLUX_BASE_MODEL_ID,
28
+ FLUX_DISTILLED_MODEL_ID,
29
+ FLUX_MODEL_CAPABILITY,
30
+ FLUX_MODEL_ID,
31
+ FLUX_PROVIDER_ID,
32
+ FluxProviderAdapter,
33
+ )
34
+ from app.generation.providers.worker_client import RemoteWorkerClient
35
+ from app.generation.schemas.requests import GenerationRequestCreate
36
+
37
+
38
+ async def _no_sleep() -> None:
39
+ return None
40
+
41
+
42
+ def _client(
43
+ handler: Callable[[httpx.Request], httpx.Response], *, retries: int = 2
44
+ ) -> RemoteWorkerClient:
45
+ return RemoteWorkerClient(
46
+ base_url="https://flux-worker.example",
47
+ bearer_token="x" * 32,
48
+ connect_timeout_seconds=1,
49
+ request_timeout_seconds=1,
50
+ read_timeout_seconds=1,
51
+ retry_policy=GenerationRetryPolicy(max_retries=retries, backoff_seconds=0),
52
+ http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
53
+ sleep=lambda _: _no_sleep(),
54
+ )
55
+
56
+
57
+ def _info() -> dict[str, object]:
58
+ return {
59
+ "id": FLUX_MODEL_ID,
60
+ "name": "FLUX.2 Klein 4B",
61
+ "type": "image",
62
+ "license": "Apache-2.0",
63
+ "status": "ready",
64
+ "models": {"distilled": FLUX_DISTILLED_MODEL_ID, "base": FLUX_BASE_MODEL_ID},
65
+ }
66
+
67
+
68
+ def _payload(**overrides: object) -> GenerationRequestCreate:
69
+ value: dict[str, object] = {
70
+ "provider": FLUX_PROVIDER_ID,
71
+ "model_id": FLUX_MODEL_ID,
72
+ "modality": "image",
73
+ "prompt": "A cinematic coastal city at sunrise",
74
+ "flux": {
75
+ "mode_choice": "Distilled (4 steps)",
76
+ "seed": 42,
77
+ "randomize_seed": False,
78
+ "width": 1024,
79
+ "height": 1024,
80
+ "num_inference_steps": 4,
81
+ "guidance_scale": 1.0,
82
+ "prompt_upsampling": False,
83
+ },
84
+ }
85
+ value.update(overrides)
86
+ return GenerationRequestCreate.model_validate(value)
87
+
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_flux_exact_model_discovery_and_readiness() -> None:
91
+ def handler(request: httpx.Request) -> httpx.Response:
92
+ if request.url.path == "/health":
93
+ return httpx.Response(200, json={"status": "ok"})
94
+ if request.url.path == "/ready":
95
+ return httpx.Response(
96
+ 200,
97
+ json={
98
+ "status": "ready",
99
+ "model_loaded": True,
100
+ "model": FLUX_MODEL_ID,
101
+ "accepting_jobs": True,
102
+ },
103
+ )
104
+ return httpx.Response(200, json=_info())
105
+
106
+ adapter = FluxProviderAdapter(client=_client(handler))
107
+ registry = GenerationModelRegistry(
108
+ [
109
+ GenerationModelRegistration(
110
+ provider_id=FLUX_PROVIDER_ID,
111
+ model=FLUX_MODEL_CAPABILITY,
112
+ configuration_reference="flux-space",
113
+ )
114
+ ]
115
+ )
116
+ assert (await adapter.health()).status.value == "healthy"
117
+ models = registry.verify_readiness(
118
+ provider_id=FLUX_PROVIDER_ID,
119
+ worker_info=await adapter.info(),
120
+ readiness=await adapter.ready(),
121
+ provider_configured=adapter.available,
122
+ )
123
+ assert models[0].model.id == FLUX_MODEL_ID
124
+ assert models[0].model.modality is GenerationModality.IMAGE
125
+ assert models[0].available
126
+
127
+
128
+ @pytest.mark.asyncio
129
+ async def test_flux_identity_mismatch_and_not_ready_are_not_advertised() -> None:
130
+ wrong = {**_info(), "models": {"distilled": "untrusted/model", "base": FLUX_BASE_MODEL_ID}}
131
+
132
+ def identity_handler(_: httpx.Request) -> httpx.Response:
133
+ return httpx.Response(200, json=wrong)
134
+
135
+ with pytest.raises(GenerationWorkerError) as raised:
136
+ await FluxProviderAdapter(client=_client(identity_handler)).info()
137
+ assert raised.value.category is WorkerErrorCategory.PROVIDER_ERROR
138
+
139
+ def not_ready_handler(request: httpx.Request) -> httpx.Response:
140
+ if request.url.path == "/ready":
141
+ return httpx.Response(
142
+ 503,
143
+ json={
144
+ "status": "not_ready",
145
+ "model_loaded": False,
146
+ "model": FLUX_MODEL_ID,
147
+ "accepting_jobs": False,
148
+ },
149
+ )
150
+ return httpx.Response(200, json=_info())
151
+
152
+ with pytest.raises(GenerationWorkerError) as raised:
153
+ await FluxProviderAdapter(client=_client(not_ready_handler)).ready()
154
+ assert raised.value.category is WorkerErrorCategory.WORKER_NOT_READY
155
+
156
+
157
+ @pytest.mark.asyncio
158
+ async def test_flux_text_submission_uses_strict_form_and_has_no_automatic_retry() -> None:
159
+ requests: list[httpx.Request] = []
160
+
161
+ def handler(request: httpx.Request) -> httpx.Response:
162
+ requests.append(request)
163
+ return httpx.Response(202, json={"job_id": "flux_" + "a" * 32, "status": "queued"})
164
+
165
+ job = await FluxProviderAdapter(client=_client(handler)).submit(
166
+ payload={"prompt": "A city at sunrise", "flux": {"width": 1024, "height": 1024}},
167
+ idempotency_key="generation-request-id",
168
+ )
169
+ assert job.status is WorkerJobStatus.QUEUED
170
+ assert requests[0].headers["authorization"] == "Bearer " + "x" * 32
171
+ assert requests[0].headers["content-type"].startswith("application/x-www-form-urlencoded")
172
+ assert b"width=1024" in requests[0].content
173
+
174
+
175
+ @pytest.mark.asyncio
176
+ async def test_flux_optional_canonical_image_uses_multipart(tmp_path: Path) -> None:
177
+ source = tmp_path / "input.png"
178
+ source.write_bytes(b"image-input")
179
+
180
+ def handler(request: httpx.Request) -> httpx.Response:
181
+ body = request.content.decode("latin-1")
182
+ assert 'name="input_images"' in body
183
+ assert 'name="prompt"' in body
184
+ return httpx.Response(202, json={"job_id": "flux_" + "b" * 32, "status": "queued"})
185
+
186
+ job = await FluxProviderAdapter(client=_client(handler)).submit(
187
+ payload={"prompt": "Edit this image"},
188
+ idempotency_key="generation-request-id",
189
+ input_path=source,
190
+ input_mime_type="image/png",
191
+ )
192
+ assert job.external_job_id.startswith("flux_")
193
+
194
+
195
+ @pytest.mark.asyncio
196
+ async def test_flux_rejects_invalid_requests_and_input_assets() -> None:
197
+ adapter = FluxProviderAdapter(client=None)
198
+ for invalid in ({"prompt": " "}, {"modality": "video"}):
199
+ with pytest.raises(Exception):
200
+ await adapter.validate_request(_payload(**invalid))
201
+
202
+ with pytest.raises(GenerationValidationError):
203
+ await adapter.validate_input_asset(
204
+ _payload(), SimpleNamespace(mime_type="video/mp4", file_size=100)
205
+ )
206
+ with pytest.raises(GenerationValidationError):
207
+ await adapter.validate_input_asset(
208
+ _payload(), SimpleNamespace(mime_type="image/png", file_size=21 * 1024 * 1024)
209
+ )
210
+
211
+
212
+ @pytest.mark.parametrize(
213
+ "field,value",
214
+ [
215
+ ("negative_prompt", "unsupported"),
216
+ ("scheduler", "unsupported"),
217
+ ("width", 1023),
218
+ ("height", 1032),
219
+ ],
220
+ )
221
+ def test_flux_schema_rejects_unsupported_or_invalid_parameters(field: str, value: object) -> None:
222
+ raw = _payload().model_dump()
223
+ flux = dict(raw["flux"] or {})
224
+ flux[field] = value
225
+ raw["flux"] = flux
226
+ with pytest.raises(ValueError):
227
+ GenerationRequestCreate.model_validate(raw)
228
+
229
+
230
+ @pytest.mark.asyncio
231
+ async def test_flux_rejects_controls_for_another_provider() -> None:
232
+ payload = _payload(wan={"duration_seconds": 1.0})
233
+ with pytest.raises(GenerationCapabilityUnsupportedError):
234
+ await FluxProviderAdapter(client=None).validate_request(payload)
235
+
236
+
237
+ @pytest.mark.asyncio
238
+ async def test_flux_completed_job_maps_a_safe_png_output_and_retrieves_it() -> None:
239
+ job_id = "flux_" + "c" * 32
240
+
241
+ def handler(request: httpx.Request) -> httpx.Response:
242
+ if request.url.path.endswith("/output"):
243
+ return httpx.Response(200, content=b"png-output")
244
+ return httpx.Response(
245
+ 200,
246
+ json={
247
+ "job_id": job_id,
248
+ "status": "completed",
249
+ "output": {"type": "image", "filename": "output.png"},
250
+ },
251
+ )
252
+
253
+ adapter = FluxProviderAdapter(client=_client(handler))
254
+ job = await adapter.get_job(external_job_id=job_id)
255
+ assert job.output is not None
256
+ assert job.output.mime_type == "image/png"
257
+ assert job.output.download_path == f"/v1/jobs/{job_id}/output"
258
+ output = await adapter.retrieve_output(external_job_id=job_id)
259
+ async with adapter.stream_output(output) as chunks:
260
+ assert b"".join([chunk async for chunk in chunks]) == b"png-output"
261
+
262
+
263
+ @pytest.mark.asyncio
264
+ @pytest.mark.parametrize("status_code", [429, 502, 503, 504])
265
+ async def test_flux_polling_uses_shared_bounded_transient_retry(status_code: int) -> None:
266
+ calls = 0
267
+ job_id = "flux_" + "d" * 32
268
+
269
+ def handler(_: httpx.Request) -> httpx.Response:
270
+ nonlocal calls
271
+ calls += 1
272
+ if calls < 3:
273
+ return httpx.Response(status_code, json={"detail": {"token": "never-store"}})
274
+ return httpx.Response(200, json={"job_id": job_id, "status": "running"})
275
+
276
+ job = await FluxProviderAdapter(client=_client(handler, retries=2)).get_job(
277
+ external_job_id=job_id
278
+ )
279
+ assert job.status is WorkerJobStatus.RUNNING
280
+ assert calls == 3
281
+
282
+
283
+ @pytest.mark.asyncio
284
+ async def test_flux_permanent_error_is_not_retried_and_cancellation_is_accurate() -> None:
285
+ job_id = "flux_" + "e" * 32
286
+ calls = 0
287
+
288
+ def permanent_handler(_: httpx.Request) -> httpx.Response:
289
+ nonlocal calls
290
+ calls += 1
291
+ return httpx.Response(400, json={"detail": {"code": "FLUX_REQUEST_INVALID"}})
292
+
293
+ with pytest.raises(GenerationWorkerError) as raised:
294
+ await FluxProviderAdapter(client=_client(permanent_handler, retries=3)).get_job(
295
+ external_job_id=job_id
296
+ )
297
+ assert raised.value.category is WorkerErrorCategory.INVALID_REQUEST
298
+ assert calls == 1
299
+
300
+ def queued_handler(_: httpx.Request) -> httpx.Response:
301
+ return httpx.Response(200, json={"job_id": job_id, "status": "cancelled"})
302
+
303
+ def running_handler(_: httpx.Request) -> httpx.Response:
304
+ return httpx.Response(
305
+ 409,
306
+ json={"detail": {"code": "FLUX_JOB_NOT_CANCELLABLE", "status": "running"}},
307
+ )
308
+
309
+ assert (
310
+ await FluxProviderAdapter(client=_client(queued_handler)).cancel(external_job_id=job_id)
311
+ ).status is WorkerCancellationStatus.CANCELLED
312
+ assert (
313
+ await FluxProviderAdapter(client=_client(running_handler)).cancel(external_job_id=job_id)
314
+ ).status is WorkerCancellationStatus.FAILED
315
+
316
+
317
+ def test_flux_configuration_is_optional_and_does_not_change_wan_configuration() -> None:
318
+ disabled = FluxProviderAdapter.from_settings(Settings(_env_file=None))
319
+ invalid = FluxProviderAdapter.from_settings(
320
+ Settings(_env_file=None, flux_space_url="https://flux-worker.example")
321
+ )
322
+ assert not disabled.available
323
+ assert not invalid.available
324
+ assert invalid.configuration_error is not None
tests/test_generation_foundation.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from contextlib import asynccontextmanager
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+
10
+ from app.container import build_container
11
+ from app.ai.schemas import AiGenerateImageRequest
12
+ from app.core.config import Settings
13
+ from app.generation.domain.capabilities import (
14
+ GenerationModelCapability,
15
+ GenerationProviderCapabilities,
16
+ )
17
+ from app.generation.domain.enums import (
18
+ GenerationJobStatus,
19
+ GenerationModality,
20
+ WorkerCancellationStatus,
21
+ WorkerHealthStatus,
22
+ WorkerJobStatus,
23
+ WorkerReadinessStatus,
24
+ )
25
+ from app.generation.domain.errors import (
26
+ GenerationIdempotencyConflictError,
27
+ GenerationInputAssetNotFoundError,
28
+ GenerationJobNotFoundError,
29
+ GenerationProviderJobConflictError,
30
+ )
31
+ from app.generation.domain.runtime import (
32
+ WorkerCancellationResult,
33
+ WorkerHealth,
34
+ WorkerInfo,
35
+ WorkerJob,
36
+ WorkerOutput,
37
+ WorkerReadiness,
38
+ )
39
+ from app.generation.model_registry import (
40
+ GenerationModelRegistration,
41
+ GenerationModelRegistry,
42
+ )
43
+ from app.generation.providers.base import GenerationProviderAdapter
44
+ from app.generation.providers.registry import GenerationProviderRegistry
45
+ from app.generation.schemas.requests import GenerationRequestCreate
46
+ from app.security.schemas import APIKeyCreate
47
+ from main import create_app
48
+
49
+
50
+ def generation_settings(tmp_path: Path) -> Settings:
51
+ return Settings(
52
+ _env_file=None,
53
+ auth_enabled=True,
54
+ database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
55
+ social_database_url=f"sqlite+aiosqlite:///{tmp_path / 'social.db'}",
56
+ social_auto_migrate=True,
57
+ social_worker_enabled=False,
58
+ social_oauth_encryption_key="test-only-encryption-material",
59
+ temp_dir=tmp_path / "temp",
60
+ output_dir=tmp_path / "outputs",
61
+ cleanup_interval_seconds=3600,
62
+ whisper_model="tiny",
63
+ generation_enabled=True,
64
+ )
65
+
66
+
67
+ class AvailableTestProvider(GenerationProviderAdapter):
68
+ capabilities = GenerationProviderCapabilities(
69
+ provider="test-generation",
70
+ name="Test generation adapter",
71
+ implementation_status="test",
72
+ models=[
73
+ GenerationModelCapability(
74
+ id="test-image-v1",
75
+ name="Test image v1",
76
+ modality=GenerationModality.IMAGE,
77
+ input_asset_supported=True,
78
+ )
79
+ ],
80
+ )
81
+
82
+ def __init__(self) -> None:
83
+ self.cancellation_result = WorkerCancellationResult(
84
+ status=WorkerCancellationStatus.REQUESTED
85
+ )
86
+
87
+ @property
88
+ def available(self) -> bool:
89
+ return True
90
+
91
+ async def validate_request(self, payload: GenerationRequestCreate) -> dict[str, object]:
92
+ return {"prompt": payload.prompt}
93
+
94
+ async def health(self) -> WorkerHealth:
95
+ return WorkerHealth(status=WorkerHealthStatus.HEALTHY)
96
+
97
+ async def info(self) -> WorkerInfo:
98
+ return WorkerInfo(
99
+ id="test-generation-worker",
100
+ name="Test generation worker",
101
+ media_types=[GenerationModality.IMAGE],
102
+ models=[
103
+ {
104
+ "id": "test-image-v1",
105
+ "name": "Test image v1",
106
+ "media_types": [GenerationModality.IMAGE],
107
+ }
108
+ ],
109
+ status=WorkerHealthStatus.HEALTHY,
110
+ )
111
+
112
+ async def ready(self) -> WorkerReadiness:
113
+ return WorkerReadiness(
114
+ status=WorkerReadinessStatus.READY,
115
+ model_loaded=True,
116
+ model_ids=["test-image-v1"],
117
+ )
118
+
119
+ async def cancel(self, *, external_job_id: str) -> WorkerCancellationResult:
120
+ assert external_job_id == "worker-job-1"
121
+ return self.cancellation_result
122
+
123
+ async def get_job(self, *, external_job_id: str) -> WorkerJob:
124
+ assert external_job_id == "worker-job-1"
125
+ return WorkerJob(
126
+ external_job_id=external_job_id,
127
+ status=WorkerJobStatus.COMPLETED,
128
+ output=WorkerOutput(
129
+ output_type=GenerationModality.IMAGE,
130
+ mime_type="image/png",
131
+ provider_output_id="worker-output-1",
132
+ download_path="/v1/outputs/worker-output-1",
133
+ ),
134
+ )
135
+
136
+ @asynccontextmanager
137
+ async def stream_output(self, output: WorkerOutput):
138
+ assert output.provider_output_id == "worker-output-1"
139
+
140
+ async def chunks():
141
+ yield base64.b64decode(
142
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
143
+ "AAAADUlEQVQIHWP4z8DwHwAFgAI/ScL9aQAAAABJRU5ErkJggg=="
144
+ )
145
+
146
+ yield chunks()
147
+
148
+
149
+ async def create_context(container, name: str):
150
+ _, secret = await container.api_keys.create(
151
+ APIKeyCreate(
152
+ name=name,
153
+ environment="test",
154
+ role=None,
155
+ scopes=[
156
+ "generation:providers:read",
157
+ "generation:requests:read",
158
+ "generation:requests:create",
159
+ "generation:jobs:cancel",
160
+ ],
161
+ ),
162
+ created_by="tests",
163
+ )
164
+ return await container.api_keys.authenticate(secret)
165
+
166
+
167
+ def request_payload(*, prompt: str = "A test image") -> GenerationRequestCreate:
168
+ return GenerationRequestCreate(
169
+ provider="test-generation",
170
+ model_id="test-image-v1",
171
+ modality=GenerationModality.IMAGE,
172
+ prompt=prompt,
173
+ )
174
+
175
+
176
+ @pytest.fixture
177
+ async def generation_container(tmp_path: Path):
178
+ container = build_container(generation_settings(tmp_path))
179
+ await container.security_database.initialize()
180
+ provider = AvailableTestProvider()
181
+ container.generation.providers = GenerationProviderRegistry([provider])
182
+ container.generation.models = GenerationModelRegistry(
183
+ [
184
+ GenerationModelRegistration(
185
+ provider_id=provider.provider,
186
+ model=provider.capabilities.models[0],
187
+ configuration_reference="test-generation-worker",
188
+ )
189
+ ]
190
+ )
191
+ await container.generation.initialize()
192
+ await container.generation.refresh_provider_runtime(provider.provider)
193
+ try:
194
+ yield container
195
+ finally:
196
+ await container.security_database.close()
197
+
198
+
199
+ @pytest.mark.asyncio
200
+ async def test_optional_generation_providers_start_unavailable_without_configuration(
201
+ tmp_path: Path,
202
+ ) -> None:
203
+ container = build_container(generation_settings(tmp_path))
204
+ await container.security_database.initialize()
205
+ await container.generation.initialize()
206
+ try:
207
+ providers = container.generation.list_providers()
208
+ assert [provider.capabilities.provider for provider in providers] == ["flux", "wan"]
209
+ assert not any(provider.available for provider in providers)
210
+ assert not container.generation.get_model("flux", "flux.2-klein-4b").available
211
+ assert not container.generation.get_model("wan", "wan2.2").available
212
+ finally:
213
+ await container.security_database.close()
214
+
215
+
216
+ @pytest.mark.asyncio
217
+ async def test_ai_studio_advertises_and_isolates_real_generation_history(
218
+ generation_container,
219
+ ) -> None:
220
+ context = await create_context(generation_container, "AI Studio")
221
+ capabilities = generation_container.ai.capabilities()
222
+ image_tool = next(tool for tool in capabilities.tools if tool.operation == "generate_image")
223
+ video_tool = next(tool for tool in capabilities.tools if tool.operation == "generate_video")
224
+ assert image_tool.available
225
+ assert not video_tool.available
226
+
227
+ ordinary = await generation_container.generation.create(
228
+ workspace_id=context.workspace_id,
229
+ user_id=context.user_id,
230
+ payload=request_payload(prompt="ordinary generation"),
231
+ idempotency_key="ordinary-generation-key",
232
+ )
233
+ ai_job = await generation_container.ai.create(
234
+ workspace_id=context.workspace_id,
235
+ user_id=context.user_id,
236
+ api_key_id=context.api_key_id,
237
+ request_id="ai-request",
238
+ payload=AiGenerateImageRequest(
239
+ operation="generate_image",
240
+ prompt="AI Studio generation",
241
+ ),
242
+ idempotency_key="ai-studio-generation-key",
243
+ )
244
+ history = await generation_container.ai.history(
245
+ workspace_id=context.workspace_id,
246
+ user_id=context.user_id,
247
+ offset=0,
248
+ limit=25,
249
+ )
250
+ assert [item.generation_id for item in history.items] == [ai_job.generation_id]
251
+ assert ordinary.id not in {item.generation_id for item in history.items}
252
+
253
+
254
+ def test_application_starts_with_optional_providers_disabled_when_unconfigured(
255
+ tmp_path: Path,
256
+ ) -> None:
257
+ """No worker URL/token is needed merely to start the application."""
258
+
259
+ with TestClient(create_app(generation_settings(tmp_path))) as client:
260
+ providers = client.app.state.container.generation.list_providers()
261
+ assert [provider.capabilities.provider for provider in providers] == ["flux", "wan"]
262
+ models = client.app.state.container.generation.list_models()
263
+ assert [model.model.id for model in models] == ["flux.2-klein-4b", "wan2.2"]
264
+ assert not any(model.available for model in models)
265
+
266
+
267
+ @pytest.mark.asyncio
268
+ async def test_provider_discovery_requires_a_verified_model(generation_container) -> None:
269
+ """A configured adapter is not publicly usable before runtime verification."""
270
+
271
+ provider_id = "test-generation"
272
+ generation_container.generation.models.mark_unavailable(provider_id)
273
+ assert not generation_container.generation.get_provider(provider_id).available
274
+ assert not generation_container.generation.list_providers()[0].available
275
+
276
+ await generation_container.generation.refresh_provider_runtime(provider_id)
277
+ assert generation_container.generation.get_provider(provider_id).available
278
+
279
+
280
+ @pytest.mark.asyncio
281
+ async def test_generation_request_idempotency_and_cancel(generation_container) -> None:
282
+ context = await create_context(generation_container, "Generation A")
283
+ workspace_id = str(context.workspace_id)
284
+ user_id = str(context.user_id)
285
+
286
+ first = await generation_container.generation.create(
287
+ workspace_id=workspace_id,
288
+ user_id=user_id,
289
+ payload=request_payload(),
290
+ idempotency_key="generation-request-key",
291
+ )
292
+ replay = await generation_container.generation.create(
293
+ workspace_id=workspace_id,
294
+ user_id=user_id,
295
+ payload=request_payload(),
296
+ idempotency_key="generation-request-key",
297
+ )
298
+ assert replay.id == first.id
299
+ assert replay.job.id == first.job.id
300
+ with pytest.raises(GenerationIdempotencyConflictError):
301
+ await generation_container.generation.create(
302
+ workspace_id=workspace_id,
303
+ user_id=user_id,
304
+ payload=request_payload(prompt="Different request"),
305
+ idempotency_key="generation-request-key",
306
+ )
307
+
308
+ cancelled = await generation_container.generation.cancel(workspace_id, user_id, first.job.id)
309
+ assert cancelled.status is GenerationJobStatus.CANCELLED
310
+ retrieved = await generation_container.generation.get_request(workspace_id, user_id, first.id)
311
+ assert retrieved.status is GenerationJobStatus.CANCELLED
312
+
313
+
314
+ @pytest.mark.asyncio
315
+ async def test_generation_records_are_workspace_isolated(generation_container) -> None:
316
+ context_a = await create_context(generation_container, "Generation A")
317
+ context_b = await create_context(generation_container, "Generation B")
318
+ created = await generation_container.generation.create(
319
+ workspace_id=str(context_a.workspace_id),
320
+ user_id=str(context_a.user_id),
321
+ payload=request_payload(),
322
+ idempotency_key="generation-isolation-key",
323
+ )
324
+ with pytest.raises(GenerationJobNotFoundError):
325
+ await generation_container.generation.get_job(
326
+ str(context_b.workspace_id), str(context_b.user_id), created.job.id
327
+ )
328
+ assert (
329
+ await generation_container.generation.list_requests(
330
+ str(context_b.workspace_id), str(context_b.user_id)
331
+ )
332
+ == []
333
+ )
334
+
335
+
336
+ @pytest.mark.asyncio
337
+ async def test_generation_rejects_another_workspace_canonical_input_asset(
338
+ generation_container,
339
+ ) -> None:
340
+ context_a = await create_context(generation_container, "Generation A")
341
+ context_b = await create_context(generation_container, "Generation B")
342
+ request_id = "00000000-0000-0000-0000-000000000010"
343
+ output_dir = generation_container.settings.output_dir / request_id
344
+ output_dir.mkdir(parents=True)
345
+ output = output_dir / "owned-input.png"
346
+ output.write_bytes(b"canonical image")
347
+ asset = await generation_container.assets.register_output(
348
+ workspace_id=str(context_a.workspace_id),
349
+ user_id=str(context_a.user_id),
350
+ request_id=request_id,
351
+ path=output,
352
+ mime_type="image/png",
353
+ )
354
+ with pytest.raises(GenerationInputAssetNotFoundError):
355
+ await generation_container.generation.create(
356
+ workspace_id=str(context_b.workspace_id),
357
+ user_id=str(context_b.user_id),
358
+ payload=GenerationRequestCreate(
359
+ provider="test-generation",
360
+ model_id="test-image-v1",
361
+ modality=GenerationModality.IMAGE,
362
+ prompt="Use another workspace asset",
363
+ input_asset_id=asset.id,
364
+ ),
365
+ idempotency_key="generation-cross-asset-key",
366
+ )
367
+
368
+
369
+ @pytest.mark.parametrize("forbidden_field", ["provider_payload", "worker_url", "output_url"])
370
+ def test_generation_request_schema_rejects_client_supplied_provider_controls(
371
+ forbidden_field: str,
372
+ ) -> None:
373
+ payload: dict[str, object] = {
374
+ "provider": "test-generation",
375
+ "model_id": "test-image-v1",
376
+ "modality": "image",
377
+ "prompt": "A test image",
378
+ }
379
+ payload[forbidden_field] = {"unsafe": True}
380
+ with pytest.raises(ValueError):
381
+ GenerationRequestCreate.model_validate(payload)
382
+
383
+
384
+ @pytest.mark.asyncio
385
+ async def test_remote_cancellation_preserves_requested_and_confirmed_states(
386
+ generation_container,
387
+ ) -> None:
388
+ context = await create_context(generation_container, "Generation cancellation")
389
+ workspace_id = str(context.workspace_id)
390
+ user_id = str(context.user_id)
391
+ created = await generation_container.generation.create(
392
+ workspace_id=workspace_id,
393
+ user_id=user_id,
394
+ payload=request_payload(),
395
+ idempotency_key="generation-cancellation-key",
396
+ )
397
+ await generation_container.generation.repository.transition_job(
398
+ workspace_id,
399
+ created.job.id,
400
+ GenerationJobStatus.SUBMITTING,
401
+ user_id=user_id,
402
+ )
403
+ await generation_container.generation.bind_provider_job(
404
+ workspace_id=workspace_id,
405
+ user_id=user_id,
406
+ job_id=created.job.id,
407
+ worker_job_id="worker-job-1",
408
+ )
409
+ await generation_container.generation.repository.transition_job(
410
+ workspace_id,
411
+ created.job.id,
412
+ GenerationJobStatus.RUNNING,
413
+ user_id=user_id,
414
+ )
415
+
416
+ requested = await generation_container.generation.cancel(workspace_id, user_id, created.job.id)
417
+ assert requested.status is GenerationJobStatus.CANCEL_REQUESTED
418
+
419
+ provider = generation_container.generation.providers.get("test-generation")
420
+ assert isinstance(provider, AvailableTestProvider)
421
+ provider.cancellation_result = WorkerCancellationResult(
422
+ status=WorkerCancellationStatus.CANCELLED
423
+ )
424
+ confirmed = await generation_container.generation.cancel(workspace_id, user_id, created.job.id)
425
+ assert confirmed.status is GenerationJobStatus.CANCELLED
426
+
427
+
428
+ @pytest.mark.asyncio
429
+ async def test_provider_job_binding_and_output_ingestion_are_workspace_scoped(
430
+ generation_container,
431
+ ) -> None:
432
+ context_a = await create_context(generation_container, "Generation output A")
433
+ context_b = await create_context(generation_container, "Generation output B")
434
+ workspace_a, user_a = str(context_a.workspace_id), str(context_a.user_id)
435
+ workspace_b, user_b = str(context_b.workspace_id), str(context_b.user_id)
436
+ job_a = await generation_container.generation.create(
437
+ workspace_id=workspace_a,
438
+ user_id=user_a,
439
+ payload=request_payload(),
440
+ idempotency_key="generation-output-a",
441
+ )
442
+ job_b = await generation_container.generation.create(
443
+ workspace_id=workspace_b,
444
+ user_id=user_b,
445
+ payload=request_payload(),
446
+ idempotency_key="generation-output-b",
447
+ )
448
+ for workspace_id, user_id, job_id in (
449
+ (workspace_a, user_a, job_a.job.id),
450
+ (workspace_b, user_b, job_b.job.id),
451
+ ):
452
+ await generation_container.generation.repository.transition_job(
453
+ workspace_id,
454
+ job_id,
455
+ GenerationJobStatus.SUBMITTING,
456
+ user_id=user_id,
457
+ )
458
+
459
+ await generation_container.generation.bind_provider_job(
460
+ workspace_id=workspace_a,
461
+ user_id=user_a,
462
+ job_id=job_a.job.id,
463
+ worker_job_id="worker-job-1",
464
+ )
465
+ with pytest.raises(GenerationProviderJobConflictError):
466
+ await generation_container.generation.bind_provider_job(
467
+ workspace_id=workspace_b,
468
+ user_id=user_b,
469
+ job_id=job_b.job.id,
470
+ worker_job_id="worker-job-1",
471
+ )
472
+
473
+ await generation_container.generation.repository.transition_job(
474
+ workspace_a,
475
+ job_a.job.id,
476
+ GenerationJobStatus.RUNNING,
477
+ user_id=user_a,
478
+ )
479
+ completed = await generation_container.generation.ingest_completed_provider_output(
480
+ workspace_id=workspace_a,
481
+ user_id=user_a,
482
+ job_id=job_a.job.id,
483
+ )
484
+ assert completed.status is GenerationJobStatus.SUCCEEDED
485
+ assert completed.output_asset_id is not None
486
+ output_asset = await generation_container.assets.get_owned_by_id(
487
+ workspace_id=workspace_a,
488
+ user_id=user_a,
489
+ asset_id=completed.output_asset_id,
490
+ )
491
+ assert output_asset.mime_type == "image/png"
492
+ assert output_asset.metadata_json["generation"]["media"]["resolution"] == {
493
+ "width": 1,
494
+ "height": 1,
495
+ }
496
+ assert (
497
+ await generation_container.generation.ingest_completed_provider_output(
498
+ workspace_id=workspace_a,
499
+ user_id=user_a,
500
+ job_id=job_a.job.id,
501
+ )
502
+ == completed
503
+ )
504
+ with pytest.raises(GenerationJobNotFoundError):
505
+ await generation_container.generation.ingest_completed_provider_output(
506
+ workspace_id=workspace_b,
507
+ user_id=user_b,
508
+ job_id=job_a.job.id,
509
+ )
tests/test_generation_provider_runtime.py ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+
5
+ import httpx
6
+ import pytest
7
+ from pydantic import ValidationError
8
+
9
+ from app.generation.domain.capabilities import (
10
+ GenerationModelCapability,
11
+ GenerationProviderCapabilities,
12
+ )
13
+ from app.generation.domain.enums import (
14
+ GenerationModality,
15
+ WorkerCancellationStatus,
16
+ WorkerErrorCategory,
17
+ WorkerHealthStatus,
18
+ WorkerReadinessStatus,
19
+ )
20
+ from app.generation.domain.errors import GenerationWorkerError
21
+ from app.generation.domain.retry import GenerationRetryPolicy
22
+ from app.generation.domain.runtime import WorkerInfo, WorkerOutput, WorkerReadiness
23
+ from app.generation.model_registry import (
24
+ GenerationModelRegistration,
25
+ GenerationModelRegistry,
26
+ )
27
+ from app.generation.providers.base import GenerationProviderAdapter
28
+ from app.generation.providers.registry import GenerationProviderRegistry
29
+ from app.generation.providers.worker_client import RemoteWorkerClient
30
+
31
+
32
+ def worker_client(
33
+ handler: Callable[[httpx.Request], httpx.Response] | None = None,
34
+ *,
35
+ retries: int = 2,
36
+ sleep_calls: list[float] | None = None,
37
+ ) -> RemoteWorkerClient:
38
+ async def sleep(delay: float) -> None:
39
+ if sleep_calls is not None:
40
+ sleep_calls.append(delay)
41
+
42
+ client = httpx.AsyncClient(
43
+ transport=httpx.MockTransport(
44
+ handler
45
+ or (lambda _: httpx.Response(200, json={"status": "ok"}))
46
+ )
47
+ )
48
+ return RemoteWorkerClient(
49
+ base_url="https://worker.example",
50
+ bearer_token="test-worker-token",
51
+ connect_timeout_seconds=1,
52
+ request_timeout_seconds=1,
53
+ read_timeout_seconds=1,
54
+ retry_policy=GenerationRetryPolicy(max_retries=retries, backoff_seconds=0),
55
+ http_client=client,
56
+ sleep=sleep,
57
+ )
58
+
59
+
60
+ class RuntimeTestProvider(GenerationProviderAdapter):
61
+ capabilities = GenerationProviderCapabilities(
62
+ provider="runtime-test",
63
+ name="Runtime test provider",
64
+ implementation_status="test",
65
+ models=[
66
+ GenerationModelCapability(
67
+ id="runtime-image-v1",
68
+ name="Runtime image v1",
69
+ modality=GenerationModality.IMAGE,
70
+ )
71
+ ],
72
+ )
73
+
74
+
75
+ def test_provider_and_model_registration_starts_unavailable() -> None:
76
+ provider = RuntimeTestProvider()
77
+ providers = GenerationProviderRegistry([provider])
78
+ assert providers.get("runtime-test") is provider
79
+ models = GenerationModelRegistry(
80
+ [
81
+ GenerationModelRegistration(
82
+ provider_id=provider.provider,
83
+ model=provider.capabilities.models[0],
84
+ configuration_reference="runtime-test-config",
85
+ metadata={
86
+ "access_token": "must-not-survive",
87
+ "diagnostic": (
88
+ "Bearer must-not-survive "
89
+ "https://worker.example/output?sig=secret"
90
+ ),
91
+ "download_url": "https://worker.example/output?sig=secret",
92
+ },
93
+ )
94
+ ]
95
+ )
96
+ view = models.get(provider.provider, "runtime-image-v1")
97
+ assert not view.available
98
+ assert "access_token" not in view.metadata
99
+ assert "download_url" not in view.metadata
100
+ assert "must-not-survive" not in str(view.metadata)
101
+
102
+
103
+ def test_model_availability_requires_readiness_info_and_configuration() -> None:
104
+ model = GenerationModelCapability(
105
+ id="runtime-image-v1", name="Runtime", modality=GenerationModality.IMAGE
106
+ )
107
+ registry = GenerationModelRegistry(
108
+ [
109
+ GenerationModelRegistration(
110
+ provider_id="runtime-test",
111
+ model=model,
112
+ configuration_reference="runtime-test-config",
113
+ )
114
+ ]
115
+ )
116
+ info = WorkerInfo(
117
+ id="runtime-test-worker",
118
+ name="Runtime worker",
119
+ media_types=[GenerationModality.IMAGE],
120
+ models=[
121
+ {
122
+ "id": model.id,
123
+ "name": model.name,
124
+ "media_types": [GenerationModality.IMAGE],
125
+ }
126
+ ],
127
+ )
128
+ not_ready = WorkerReadiness(
129
+ status=WorkerReadinessStatus.STARTING,
130
+ model_loaded=False,
131
+ model_ids=[model.id],
132
+ )
133
+ assert not registry.verify_readiness(
134
+ provider_id="runtime-test",
135
+ worker_info=info,
136
+ readiness=not_ready,
137
+ provider_configured=True,
138
+ )[0].available
139
+ ready = WorkerReadiness(
140
+ status=WorkerReadinessStatus.READY, model_loaded=True, model_ids=[model.id]
141
+ )
142
+ assert registry.verify_readiness(
143
+ provider_id="runtime-test",
144
+ worker_info=info,
145
+ readiness=ready,
146
+ provider_configured=True,
147
+ )[0].available
148
+
149
+
150
+ @pytest.mark.asyncio
151
+ async def test_worker_health_readiness_info_and_bearer_authentication() -> None:
152
+ seen_headers: list[str] = []
153
+
154
+ def handler(request: httpx.Request) -> httpx.Response:
155
+ seen_headers.append(request.headers.get("authorization", ""))
156
+ if request.url.path == "/health":
157
+ return httpx.Response(200, json={"status": "ok"})
158
+ if request.url.path == "/ready":
159
+ return httpx.Response(
160
+ 200,
161
+ json={"status": "ready", "model_loaded": True, "model": "model-v1"},
162
+ )
163
+ return httpx.Response(
164
+ 200,
165
+ json={"id": "model-v1", "name": "Worker model", "type": "image", "status": "ready"},
166
+ )
167
+
168
+ client = worker_client(handler)
169
+ assert (await client.health()).status is WorkerHealthStatus.HEALTHY
170
+ readiness = await client.ready()
171
+ assert readiness.status is WorkerReadinessStatus.READY
172
+ assert readiness.model_ids == ["model-v1"]
173
+ info = await client.info()
174
+ assert info.media_types == [GenerationModality.IMAGE]
175
+ assert info.models[0].id == "model-v1"
176
+ assert seen_headers == ["Bearer test-worker-token"] * 3
177
+
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_timeout_and_connection_failure_are_retryable_and_safe() -> None:
181
+ request = httpx.Request("GET", "https://worker.example/health")
182
+ for exception, category in (
183
+ (httpx.ReadTimeout("secret-token", request=request), WorkerErrorCategory.TIMEOUT),
184
+ (
185
+ httpx.ConnectError("Bearer test-worker-token", request=request),
186
+ WorkerErrorCategory.WORKER_UNAVAILABLE,
187
+ ),
188
+ ):
189
+ calls = 0
190
+
191
+ def handler(_: httpx.Request, error: Exception = exception) -> httpx.Response:
192
+ nonlocal calls
193
+ calls += 1
194
+ raise error
195
+
196
+ client = worker_client(handler, retries=1)
197
+ with pytest.raises(GenerationWorkerError) as raised:
198
+ await client.health()
199
+ assert raised.value.category is category
200
+ assert "test-worker-token" not in str(raised.value)
201
+ assert calls == 2
202
+
203
+
204
+ @pytest.mark.asyncio
205
+ @pytest.mark.parametrize("status_code", [429, 502, 503, 504])
206
+ async def test_retryable_http_failures_use_bounded_retry(status_code: int) -> None:
207
+ calls = 0
208
+ delays: list[float] = []
209
+
210
+ def handler(_: httpx.Request) -> httpx.Response:
211
+ nonlocal calls
212
+ calls += 1
213
+ if calls < 3:
214
+ return httpx.Response(status_code, json={"secret": "not surfaced"})
215
+ return httpx.Response(200, json={"status": "ok"})
216
+
217
+ client = worker_client(handler, retries=2, sleep_calls=delays)
218
+ assert (await client.health()).status is WorkerHealthStatus.HEALTHY
219
+ assert calls == 3
220
+ assert delays == [0, 0]
221
+
222
+
223
+ @pytest.mark.asyncio
224
+ @pytest.mark.parametrize("status_code", [400, 401])
225
+ async def test_non_retryable_http_failures_do_not_retry(status_code: int) -> None:
226
+ calls = 0
227
+
228
+ def handler(_: httpx.Request) -> httpx.Response:
229
+ nonlocal calls
230
+ calls += 1
231
+ return httpx.Response(status_code)
232
+
233
+ client = worker_client(handler, retries=3)
234
+ with pytest.raises(GenerationWorkerError) as raised:
235
+ await client.health()
236
+ assert calls == 1
237
+ assert raised.value.http_status == status_code
238
+
239
+
240
+ @pytest.mark.asyncio
241
+ async def test_unexpected_exception_is_not_automatically_retryable() -> None:
242
+ calls = 0
243
+
244
+ def handler(_: httpx.Request) -> httpx.Response:
245
+ nonlocal calls
246
+ calls += 1
247
+ raise RuntimeError("programming failure with secret-token")
248
+
249
+ client = worker_client(handler, retries=3)
250
+ with pytest.raises(GenerationWorkerError) as raised:
251
+ await client.health()
252
+ assert raised.value.category is WorkerErrorCategory.UNKNOWN_ERROR
253
+ assert calls == 1
254
+ assert "secret-token" not in str(raised.value)
255
+
256
+
257
+ @pytest.mark.asyncio
258
+ async def test_worker_cancellation_and_output_contract() -> None:
259
+ def handler(request: httpx.Request) -> httpx.Response:
260
+ if request.method == "POST":
261
+ return httpx.Response(202, json={"status": "cancellation_requested"})
262
+ return httpx.Response(
263
+ 200,
264
+ json={
265
+ "job_id": "job-1",
266
+ "status": "completed",
267
+ "output": {
268
+ "type": "image",
269
+ "mime_type": "image/png",
270
+ "id": "output-1",
271
+ "download_path": "/v1/outputs/output-1",
272
+ "filename": "output.png",
273
+ },
274
+ },
275
+ )
276
+
277
+ client = worker_client(handler)
278
+ cancellation = await client.cancel("job-1")
279
+ assert cancellation.status is WorkerCancellationStatus.REQUESTED
280
+ output = await client.retrieve_output("job-1")
281
+ assert output.provider_output_id == "output-1"
282
+ assert output.download_path == "/v1/outputs/output-1"
283
+ with pytest.raises(ValidationError):
284
+ WorkerOutput(
285
+ output_type=GenerationModality.IMAGE,
286
+ mime_type="image/png",
287
+ provider_output_id="output-1",
288
+ download_path="https://attacker.example/output.png",
289
+ )
290
+ with pytest.raises(ValidationError):
291
+ WorkerOutput(
292
+ output_type=GenerationModality.IMAGE,
293
+ mime_type="image/png",
294
+ provider_output_id="output-1",
295
+ download_path="/v1/outputs/%2e%2e/secrets",
296
+ )
297
+
298
+
299
+ @pytest.mark.asyncio
300
+ async def test_empty_successful_cancellation_response_means_requested_not_cancelled() -> None:
301
+ client = worker_client(lambda _: httpx.Response(204))
302
+ result = await client.cancel("job-1")
303
+ assert result.status is WorkerCancellationStatus.REQUESTED
304
+
305
+
306
+ @pytest.mark.asyncio
307
+ async def test_output_stream_is_scoped_to_the_configured_worker_origin() -> None:
308
+ client = worker_client(lambda _: httpx.Response(200, content=b"worker-output"))
309
+ output = WorkerOutput(
310
+ output_type=GenerationModality.IMAGE,
311
+ mime_type="image/png",
312
+ provider_output_id="output-1",
313
+ download_path="/v1/outputs/output-1",
314
+ )
315
+ async with client.stream_output(output) as chunks:
316
+ received = b"".join([chunk async for chunk in chunks])
317
+ assert received == b"worker-output"
318
+
319
+
320
+ @pytest.mark.asyncio
321
+ async def test_worker_info_requires_a_discovered_model_match_for_availability() -> None:
322
+ model = GenerationModelCapability(
323
+ id="runtime-image-v1", name="Runtime", modality=GenerationModality.IMAGE
324
+ )
325
+ registry = GenerationModelRegistry(
326
+ [
327
+ GenerationModelRegistration(
328
+ provider_id="runtime-test",
329
+ model=model,
330
+ configuration_reference="runtime-test-config",
331
+ )
332
+ ]
333
+ )
334
+ readiness = WorkerReadiness(
335
+ status=WorkerReadinessStatus.READY, model_loaded=True, model_ids=[model.id]
336
+ )
337
+ undiscovered = WorkerInfo(
338
+ id="worker",
339
+ name="Worker",
340
+ media_types=[GenerationModality.IMAGE],
341
+ models=[{"id": "other-model", "name": "Other", "media_types": ["image"]}],
342
+ )
343
+ assert not registry.verify_readiness(
344
+ provider_id="runtime-test",
345
+ worker_info=undiscovered,
346
+ readiness=readiness,
347
+ provider_configured=True,
348
+ )[0].available
349
+
350
+
351
+ def test_worker_url_and_path_validation_blocks_ssrf_and_traversal() -> None:
352
+ policy = GenerationRetryPolicy(max_retries=0, backoff_seconds=0)
353
+ for url in (
354
+ "http://example.com",
355
+ "https://10.0.0.1",
356
+ "http://169.254.169.254",
357
+ "https://169.254.169.254",
358
+ "https://worker.example/%2e%2e/internal",
359
+ "file:///etc/passwd",
360
+ ):
361
+ with pytest.raises(ValueError):
362
+ RemoteWorkerClient(
363
+ base_url=url,
364
+ bearer_token=None,
365
+ connect_timeout_seconds=1,
366
+ request_timeout_seconds=1,
367
+ read_timeout_seconds=1,
368
+ retry_policy=policy,
369
+ )
370
+ with pytest.raises(GenerationWorkerError):
371
+ RemoteWorkerClient._safe_external_id("job/../../metadata")
tests/test_generation_wan.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mocked protocol tests for the audited WAN 2.2 worker integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ import pytest
10
+
11
+ from app.core.config import Settings
12
+ from app.generation.domain.enums import (
13
+ GenerationModality,
14
+ WorkerCancellationStatus,
15
+ WorkerErrorCategory,
16
+ WorkerJobStatus,
17
+ )
18
+ from app.generation.domain.errors import GenerationWorkerError
19
+ from app.generation.domain.retry import GenerationRetryPolicy
20
+ from app.generation.model_registry import GenerationModelRegistration, GenerationModelRegistry
21
+ from app.generation.providers.wan import (
22
+ WAN_MODEL_CAPABILITY,
23
+ WAN_MODEL_ID,
24
+ WAN_PROVIDER_ID,
25
+ WanProviderAdapter,
26
+ )
27
+ from app.generation.providers.worker_client import RemoteWorkerClient
28
+ from app.generation.schemas.requests import GenerationRequestCreate
29
+
30
+
31
+ def _client(
32
+ handler: Callable[[httpx.Request], httpx.Response], *, retries: int = 2
33
+ ) -> RemoteWorkerClient:
34
+ return RemoteWorkerClient(
35
+ base_url="https://wan-worker.example",
36
+ bearer_token="x" * 32,
37
+ connect_timeout_seconds=1,
38
+ request_timeout_seconds=1,
39
+ read_timeout_seconds=1,
40
+ retry_policy=GenerationRetryPolicy(max_retries=retries, backoff_seconds=0),
41
+ http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
42
+ sleep=lambda _: _no_sleep(),
43
+ )
44
+
45
+
46
+ async def _no_sleep() -> None:
47
+ return None
48
+
49
+
50
+ def _payload(**overrides: object) -> GenerationRequestCreate:
51
+ value: dict[str, object] = {
52
+ "provider": WAN_PROVIDER_ID,
53
+ "model_id": WAN_MODEL_ID,
54
+ "modality": "video",
55
+ "input_asset_id": "11111111-1111-4111-8111-111111111111",
56
+ "prompt": "Slow cinematic cloud movement",
57
+ "wan": {
58
+ "duration_seconds": 0.5,
59
+ "steps": 4,
60
+ "guidance_scale": 1.0,
61
+ "guidance_scale_2": 1.0,
62
+ "seed": 42,
63
+ "randomize_seed": False,
64
+ },
65
+ }
66
+ value.update(overrides)
67
+ return GenerationRequestCreate.model_validate(value)
68
+
69
+
70
+ def _info() -> dict[str, object]:
71
+ return {
72
+ "id": "wan2.2",
73
+ "name": "WAN 2.2 FP8 AOTI Faster",
74
+ "type": "video",
75
+ "task": "image-to-video",
76
+ "status": "ready",
77
+ "model_id": "Wan-AI/Wan2.2-I2V-A14B-Diffusers",
78
+ "fps": 16,
79
+ }
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_wan_exact_model_discovery_and_readiness() -> None:
84
+ def handler(request: httpx.Request) -> httpx.Response:
85
+ if request.url.path == "/health":
86
+ return httpx.Response(200, json={"status": "ok", "service": "mediarouter-wan-worker"})
87
+ if request.url.path == "/ready":
88
+ return httpx.Response(
89
+ 200,
90
+ json={
91
+ "status": "ready",
92
+ "model_loaded": True,
93
+ "model": "wan2.2",
94
+ "accepting_jobs": True,
95
+ },
96
+ )
97
+ return httpx.Response(200, json=_info())
98
+
99
+ adapter = WanProviderAdapter(client=_client(handler))
100
+ registry = GenerationModelRegistry(
101
+ [
102
+ GenerationModelRegistration(
103
+ provider_id=WAN_PROVIDER_ID,
104
+ model=WAN_MODEL_CAPABILITY,
105
+ configuration_reference="wan-space",
106
+ )
107
+ ]
108
+ )
109
+ models = registry.verify_readiness(
110
+ provider_id=WAN_PROVIDER_ID,
111
+ worker_info=await adapter.info(),
112
+ readiness=await adapter.ready(),
113
+ provider_configured=adapter.available,
114
+ )
115
+ assert models[0].model.id == WAN_MODEL_ID
116
+ assert models[0].model.modality is GenerationModality.VIDEO
117
+ assert models[0].available
118
+
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_wan_not_ready_and_model_mismatch_are_not_advertised() -> None:
122
+ def handler(request: httpx.Request) -> httpx.Response:
123
+ if request.url.path == "/ready":
124
+ return httpx.Response(
125
+ 503,
126
+ json={
127
+ "status": "not_ready",
128
+ "model_loaded": False,
129
+ "model": "other-model",
130
+ "accepting_jobs": False,
131
+ },
132
+ )
133
+ return httpx.Response(
134
+ 200, json={"status": "ok"} if request.url.path == "/health" else _info()
135
+ )
136
+
137
+ adapter = WanProviderAdapter(client=_client(handler))
138
+ with pytest.raises(GenerationWorkerError) as raised:
139
+ await adapter.ready()
140
+ assert raised.value.category is WorkerErrorCategory.WORKER_NOT_READY
141
+
142
+
143
+ @pytest.mark.asyncio
144
+ async def test_wan_model_identity_mismatch_remains_unavailable() -> None:
145
+ wrong_info = {
146
+ **_info(),
147
+ "id": "different-wan-model",
148
+ "name": "Different model",
149
+ }
150
+
151
+ def handler(request: httpx.Request) -> httpx.Response:
152
+ if request.url.path == "/ready":
153
+ return httpx.Response(
154
+ 200,
155
+ json={
156
+ "status": "ready",
157
+ "model_loaded": True,
158
+ "model": WAN_MODEL_ID,
159
+ "accepting_jobs": True,
160
+ },
161
+ )
162
+ return httpx.Response(200, json=wrong_info)
163
+
164
+ adapter = WanProviderAdapter(client=_client(handler))
165
+ with pytest.raises(GenerationWorkerError) as raised:
166
+ await adapter.info()
167
+ assert raised.value.category is WorkerErrorCategory.PROVIDER_ERROR
168
+
169
+
170
+ @pytest.mark.asyncio
171
+ async def test_wan_submission_is_multipart_and_has_no_automatic_retry(tmp_path: Path) -> None:
172
+ seen: list[httpx.Request] = []
173
+
174
+ def handler(request: httpx.Request) -> httpx.Response:
175
+ seen.append(request)
176
+ return httpx.Response(202, json={"job_id": "wan_" + "a" * 32, "status": "queued"})
177
+
178
+ source = tmp_path / "input.png"
179
+ source.write_bytes(b"not-decoded-in-adapter-test")
180
+ adapter = WanProviderAdapter(client=_client(handler))
181
+ job = await adapter.submit(
182
+ payload={"prompt": "slow movement", "wan": {"duration_seconds": 0.5, "steps": 4}},
183
+ idempotency_key="generation-request-id",
184
+ input_path=source,
185
+ input_mime_type="image/png",
186
+ )
187
+ assert job.status is WorkerJobStatus.QUEUED
188
+ assert job.external_job_id.startswith("wan_")
189
+ assert seen[0].headers["authorization"] == "Bearer " + "x" * 32
190
+ body = seen[0].content.decode("latin-1")
191
+ assert 'name="image"' in body
192
+ assert 'name="duration_seconds"' in body
193
+ assert 'name="width"' not in body
194
+
195
+
196
+ @pytest.mark.asyncio
197
+ async def test_wan_submission_connection_ambiguity_is_not_retried(tmp_path: Path) -> None:
198
+ calls = 0
199
+ request = httpx.Request("POST", "https://wan-worker.example/v1/generate")
200
+
201
+ def handler(_: httpx.Request) -> httpx.Response:
202
+ nonlocal calls
203
+ calls += 1
204
+ raise httpx.ConnectError("Bearer " + "x" * 32, request=request)
205
+
206
+ source = tmp_path / "input.png"
207
+ source.write_bytes(b"input")
208
+ adapter = WanProviderAdapter(client=_client(handler, retries=3))
209
+ with pytest.raises(GenerationWorkerError) as raised:
210
+ await adapter.submit(
211
+ payload={"prompt": "slow movement"},
212
+ idempotency_key="generation-request-id",
213
+ input_path=source,
214
+ input_mime_type="image/png",
215
+ )
216
+ assert raised.value.category is WorkerErrorCategory.WORKER_UNAVAILABLE
217
+ assert calls == 1
218
+ assert "Bearer" not in str(raised.value)
219
+
220
+
221
+ @pytest.mark.asyncio
222
+ async def test_wan_completed_job_maps_a_safe_video_output() -> None:
223
+ job_id = "wan_" + "b" * 32
224
+
225
+ def handler(_: httpx.Request) -> httpx.Response:
226
+ return httpx.Response(
227
+ 200,
228
+ json={
229
+ "job_id": job_id,
230
+ "status": "completed",
231
+ "output": {"type": "video", "filename": f"{job_id}.mp4"},
232
+ },
233
+ )
234
+
235
+ adapter = WanProviderAdapter(client=_client(handler))
236
+ job = await adapter.get_job(external_job_id=job_id)
237
+ assert job.output is not None
238
+ assert job.output.mime_type == "video/mp4"
239
+ assert job.output.provider_output_id == job_id
240
+ assert job.output.download_path == f"/v1/jobs/{job_id}/output"
241
+
242
+
243
+ @pytest.mark.asyncio
244
+ @pytest.mark.parametrize("status_code", [429, 502, 503, 504])
245
+ async def test_wan_polling_uses_shared_bounded_transient_retry(status_code: int) -> None:
246
+ calls = 0
247
+ job_id = "wan_" + "d" * 32
248
+
249
+ def handler(_: httpx.Request) -> httpx.Response:
250
+ nonlocal calls
251
+ calls += 1
252
+ if calls < 3:
253
+ return httpx.Response(status_code, json={"detail": {"token": "never-store"}})
254
+ return httpx.Response(200, json={"job_id": job_id, "status": "running"})
255
+
256
+ job = await WanProviderAdapter(client=_client(handler, retries=2)).get_job(
257
+ external_job_id=job_id
258
+ )
259
+ assert job.status is WorkerJobStatus.RUNNING
260
+ assert calls == 3
261
+
262
+
263
+ @pytest.mark.asyncio
264
+ async def test_wan_polling_does_not_retry_permanent_client_errors() -> None:
265
+ calls = 0
266
+ job_id = "wan_" + "e" * 32
267
+
268
+ def handler(_: httpx.Request) -> httpx.Response:
269
+ nonlocal calls
270
+ calls += 1
271
+ return httpx.Response(400, json={"detail": {"code": "WAN_PARAMETERS_INVALID"}})
272
+
273
+ with pytest.raises(GenerationWorkerError) as raised:
274
+ await WanProviderAdapter(client=_client(handler, retries=3)).get_job(
275
+ external_job_id=job_id
276
+ )
277
+ assert raised.value.category is WorkerErrorCategory.INVALID_REQUEST
278
+ assert calls == 1
279
+
280
+
281
+ @pytest.mark.asyncio
282
+ async def test_wan_cancellation_only_confirms_queued_worker_cancellation() -> None:
283
+ def queued_handler(_: httpx.Request) -> httpx.Response:
284
+ return httpx.Response(200, json={"job_id": "wan_" + "c" * 32, "status": "cancelled"})
285
+
286
+ def running_handler(_: httpx.Request) -> httpx.Response:
287
+ return httpx.Response(
288
+ 409,
289
+ json={
290
+ "detail": {
291
+ "code": "WAN_JOB_NOT_CANCELLABLE",
292
+ "message": "A running job cannot be cancelled.",
293
+ "status": "running",
294
+ }
295
+ },
296
+ )
297
+
298
+ assert (
299
+ await WanProviderAdapter(client=_client(queued_handler)).cancel(
300
+ external_job_id="wan_" + "c" * 32
301
+ )
302
+ ).status is WorkerCancellationStatus.CANCELLED
303
+ assert (
304
+ await WanProviderAdapter(client=_client(running_handler)).cancel(
305
+ external_job_id="wan_" + "c" * 32
306
+ )
307
+ ).status is WorkerCancellationStatus.FAILED
308
+
309
+
310
+ @pytest.mark.parametrize(
311
+ "invalid",
312
+ [
313
+ {"prompt": " "},
314
+ {"modality": "image"},
315
+ {"input_asset_id": None},
316
+ ],
317
+ )
318
+ @pytest.mark.asyncio
319
+ async def test_wan_request_validation_rejects_invalid_required_values(
320
+ invalid: dict[str, object]
321
+ ) -> None:
322
+ adapter = WanProviderAdapter(client=None)
323
+ with pytest.raises(Exception):
324
+ payload = _payload(**invalid)
325
+ await adapter.validate_request(payload)
326
+
327
+
328
+ @pytest.mark.parametrize("field", ["width", "height", "num_frames", "provider_payload"])
329
+ def test_wan_schema_rejects_unsupported_parameters(field: str) -> None:
330
+ raw = _payload().model_dump()
331
+ wan = dict(raw["wan"] or {})
332
+ wan[field] = 1
333
+ raw["wan"] = wan
334
+ with pytest.raises(ValueError):
335
+ GenerationRequestCreate.model_validate(raw)
336
+
337
+
338
+ def test_wan_configuration_is_optional_and_never_enables_flux() -> None:
339
+ disabled = WanProviderAdapter.from_settings(Settings(_env_file=None))
340
+ invalid = WanProviderAdapter.from_settings(
341
+ Settings(_env_file=None, wan_space_url="https://wan-worker.example")
342
+ )
343
+ assert not disabled.available
344
+ assert not invalid.available
345
+ assert invalid.configuration_error is not None
346
+ assert WAN_PROVIDER_ID == "wan"
tests/test_health.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi.testclient import TestClient
2
+
3
+ from main import create_app
4
+
5
+
6
+ def test_health_endpoint(settings) -> None:
7
+ with TestClient(create_app(settings)) as client:
8
+ response = client.get("/health")
9
+ assert response.status_code == 200
10
+ payload = response.json()
11
+ assert payload["success"] is True
12
+ assert payload["metadata"]["status"] == "healthy"
13
+ assert response.headers["x-request-id"] == payload["request_id"]
tests/test_input_resolver.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from uuid import uuid4
5
+
6
+ import pytest
7
+ from starlette.requests import Request
8
+
9
+ from app.container import build_container
10
+ from app.core.exceptions import InputError
11
+ from app.models.media import MediaSource
12
+
13
+
14
+ def json_request(payload: bytes) -> Request:
15
+ sent = False
16
+
17
+ async def receive():
18
+ nonlocal sent
19
+ if sent:
20
+ return {"type": "http.disconnect"}
21
+ sent = True
22
+ return {"type": "http.request", "body": payload, "more_body": False}
23
+
24
+ request = Request(
25
+ {
26
+ "type": "http",
27
+ "method": "POST",
28
+ "path": "/v1/probe",
29
+ "headers": [(b"content-type", b"application/json")],
30
+ "query_string": b"",
31
+ },
32
+ receive,
33
+ )
34
+ request.state.request_id = str(uuid4())
35
+ return request
36
+
37
+
38
+ async def test_resolves_json_base64(settings) -> None:
39
+ container = build_container(settings)
40
+ encoded = base64.b64encode(b"ID3-not-real-audio").decode()
41
+ request = json_request(
42
+ ('{"base64":"%s","filename":"sample.mp3","format":"wav"}' % encoded).encode()
43
+ )
44
+ resolved = await container.resolver.resolve(request)
45
+ assert resolved.primary.source is MediaSource.JSON_BASE64
46
+ assert resolved.primary.filename == "sample.mp3"
47
+ assert resolved.primary.temp_path.read_bytes() == b"ID3-not-real-audio"
48
+ assert resolved.params["filename"] == "sample.mp3"
49
+ assert resolved.params["format"] == "wav"
50
+
51
+
52
+ async def test_resolves_n8n_binary_property(settings) -> None:
53
+ container = build_container(settings)
54
+ encoded = base64.b64encode(b"audio").decode()
55
+ payload = (
56
+ '{"binary":{"audio":{"data":"%s","fileName":"voice.mp3",'
57
+ '"mimeType":"audio/mpeg"}}}' % encoded
58
+ ).encode()
59
+ resolved = await container.resolver.resolve(json_request(payload))
60
+ assert resolved.primary.source is MediaSource.N8N_BINARY
61
+ assert resolved.primary.filename == "voice.mp3"
62
+ assert resolved.primary.temp_path.read_bytes() == b"audio"
63
+
64
+
65
+ async def test_resolves_nested_template_input(settings) -> None:
66
+ container = build_container(settings)
67
+ encoded = base64.b64encode(b"RIFF-template-audio").decode()
68
+ request = json_request(
69
+ (
70
+ '{"template":"mp3","input":{"base64":"%s",'
71
+ '"filename":"source.wav","mime_type":"audio/wav"},"parameters":{}}' % encoded
72
+ ).encode()
73
+ )
74
+
75
+ resolved = await container.resolver.resolve(request)
76
+
77
+ assert resolved.primary.source is MediaSource.JSON_BASE64
78
+ assert resolved.primary.filename == "source.wav"
79
+ assert resolved.params["template"] == "mp3"
80
+ assert resolved.params["parameters"] == {}
81
+
82
+
83
+ async def test_resolve_payload_copies_managed_temp_file(settings) -> None:
84
+ settings.output_dir.mkdir(parents=True)
85
+ source = settings.output_dir / "previous" / "clip.mp3"
86
+ source.parent.mkdir()
87
+ source.write_bytes(b"ID3-managed-media")
88
+ container = build_container(settings)
89
+
90
+ resolved = await container.resolver.resolve_payload({"temp_path": str(source)}, str(uuid4()))
91
+
92
+ assert resolved.primary.source is MediaSource.LOCAL_PATH
93
+ assert resolved.primary.temp_path != source
94
+ assert resolved.primary.temp_path.read_bytes() == source.read_bytes()
95
+
96
+
97
+ async def test_resolve_payload_rejects_unmanaged_path(settings, tmp_path) -> None:
98
+ source = tmp_path / "outside.mp3"
99
+ source.write_bytes(b"ID3-unmanaged-media")
100
+ container = build_container(settings)
101
+
102
+ with pytest.raises(InputError, match="TEMP_DIR or OUTPUT_DIR"):
103
+ await container.resolver.resolve_payload({"temp_path": str(source)}, str(uuid4()))