diff --git a/.gitignore b/.gitignore index d3cb8a3b67..8dc6088b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /.idea/ /docs/build/ -**/__pycache__/ \ No newline at end of file +**/__pycache__/ +.venv \ No newline at end of file diff --git a/build_stream/api/jobs/__init__.py b/build_stream/api/jobs/__init__.py new file mode 100644 index 0000000000..26f69cba32 --- /dev/null +++ b/build_stream/api/jobs/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .routes import router + +__all__ = ["router"] diff --git a/build_stream/api/jobs/dependencies.py b/build_stream/api/jobs/dependencies.py new file mode 100644 index 0000000000..1598528f3e --- /dev/null +++ b/build_stream/api/jobs/dependencies.py @@ -0,0 +1,120 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""FastAPI dependency providers for Jobs API.""" + +from typing import Optional + +from fastapi import Header, HTTPException, status + +from build_stream.container import container +from build_stream.core.jobs.value_objects import ClientId, CorrelationId +from build_stream.infra.id_generator import JobUUIDGenerator, UUIDv4Generator +from build_stream.infra.repositories import InMemoryJobRepository, InMemoryStageRepository +from build_stream.orchestrator.jobs.use_cases import CreateJobUseCase + + +def get_id_generator() -> JobUUIDGenerator: + """Provide job ID generator.""" + return container.job_id_generator() + + +def get_create_job_use_case() -> CreateJobUseCase: + """Provide create job use case.""" + return container.create_job_use_case() + + +def get_job_repo() -> InMemoryJobRepository: + """Provide job repository.""" + return container.job_repository() + + +def get_stage_repo() -> InMemoryStageRepository: + """Provide stage repository.""" + return container.stage_repository() + + +def get_client_id( + authorization: str = Header(..., description="Bearer token for authentication"), +) -> ClientId: + """Extract ClientId from Bearer token header.""" + if not authorization.startswith("Bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authorization header format", + ) + + # Trim only the Bearer prefix and leading whitespace; preserve trailing + # whitespace as part of token + token = authorization[7:].lstrip() + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication token", + ) + + # Implement real token validation and client_id extraction when auth is available. + # For now, use token as client_id placeholder + try: + return ClientId(token[:128] if len(token) > 128 else token) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid client credentials", + ) from e + + +def get_correlation_id( + x_correlation_id: Optional[str] = Header( + default=None, + alias="X-Correlation-Id", + description="Request tracing ID", + ), +) -> CorrelationId: + """Return provided correlation ID or generate one.""" + generator = container.uuid_generator() + if x_correlation_id: + try: + correlation_id = CorrelationId(x_correlation_id) + return correlation_id + except ValueError: + pass + + generated_id = generator.generate() + return CorrelationId(str(generated_id)) + + +def get_idempotency_key( + idempotency_key: str = Header( + ..., + alias="Idempotency-Key", + description="Client-provided deduplication token", + ), +) -> str: + """Validate and return the Idempotency-Key header.""" + if idempotency_key is None or not idempotency_key.strip(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Idempotency-Key must be provided", + ) + + key = idempotency_key.strip() + + if len(key) > 255: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Idempotency-Key length must be <= 255 characters", + ) + + return key diff --git a/build_stream/api/jobs/routes.py b/build_stream/api/jobs/routes.py new file mode 100644 index 0000000000..b9de26c6b4 --- /dev/null +++ b/build_stream/api/jobs/routes.py @@ -0,0 +1,342 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""FastAPI routes for job lifecycle operations.""" + +import logging +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Response, status + +from build_stream.core.jobs.exceptions import ( + IdempotencyConflictError, + InvalidStateTransitionError, + JobNotFoundError, +) +from build_stream.core.jobs.value_objects import ( + ClientId, + CorrelationId, + IdempotencyKey, + JobId, +) +from build_stream.orchestrator.jobs.commands import CreateJobCommand +from build_stream.orchestrator.jobs.use_cases import CreateJobUseCase + +from .dependencies import ( + get_client_id, + get_correlation_id, + get_create_job_use_case, + get_idempotency_key, + get_job_repo, + get_stage_repo, +) +from .schemas import ( + CreateJobRequest, + CreateJobResponse, + ErrorResponse, + GetJobResponse, + StageResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/jobs", tags=["Jobs"]) + + +def _build_error_response( + error_code: str, + message: str, + correlation_id: str, +) -> ErrorResponse: + return ErrorResponse( + error=error_code, + message=message, + correlation_id=correlation_id, + timestamp=datetime.now(timezone.utc).isoformat() + "Z", + ) + + +@router.post( + "", + response_model=CreateJobResponse, + status_code=status.HTTP_201_CREATED, + responses={ + 200: {"description": "Idempotent replay", "model": CreateJobResponse}, + 201: {"description": "Job created", "model": CreateJobResponse}, + 400: {"description": "Invalid request", "model": ErrorResponse}, + 401: {"description": "Unauthorized", "model": ErrorResponse}, + 409: {"description": "Idempotency conflict", "model": ErrorResponse}, + 422: {"description": "Validation error", "model": ErrorResponse}, + 500: {"description": "Internal error", "model": ErrorResponse}, + }, +) +async def create_job( + request: CreateJobRequest, + response: Response, + client_id: ClientId = Depends(get_client_id), + correlation_id: CorrelationId = Depends(get_correlation_id), + idempotency_key: str = Depends(get_idempotency_key), + use_case: CreateJobUseCase = Depends(get_create_job_use_case), + stage_repo = Depends(get_stage_repo), +) -> CreateJobResponse: + """Create a job, handling idempotency and domain errors.""" + # pylint: disable=too-many-arguments,too-many-positional-arguments + logger.info( + "Create job request: client_id=%s, correlation_id=%s, idempotency_key=%s", + client_id.value, + correlation_id.value, + idempotency_key, + ) + + try: + command = CreateJobCommand( + client_id=client_id, + request_client_id=request.client_id, + client_name=request.client_name, + correlation_id=correlation_id, + idempotency_key=IdempotencyKey(idempotency_key), + ) + result = use_case.execute(command) + # Set status code based on whether job was newly created + if result.is_new: + response.status_code = status.HTTP_201_CREATED + else: + response.status_code = status.HTTP_200_OK + stages_entities = stage_repo.find_all_by_job(JobId(result.job_id)) # pylint: disable=no-member + stages = [ + StageResponse( + stage_name=str(s.stage_name), + stage_state=s.stage_state.value, + started_at=s.started_at.isoformat() + "Z" if s.started_at else None, + ended_at=s.ended_at.isoformat() + "Z" if s.ended_at else None, + error_code=s.error_code, + error_summary=s.error_summary, + ) + for s in stages_entities + ] + return CreateJobResponse( + job_id=result.job_id, + correlation_id=correlation_id.value, + job_state=result.job_state, + created_at=result.created_at, + stages=stages, + ) + + except IdempotencyConflictError as e: + logger.warning("Idempotency conflict occurred") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=_build_error_response( + "IDEMPOTENCY_CONFLICT", + e.message, + correlation_id.value, + ).model_dump(), + ) from e + + except Exception as e: + logger.exception("Unexpected error creating job") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=_build_error_response( + "INTERNAL_ERROR", + "An unexpected error occurred", + correlation_id.value, + ).model_dump(), + ) from e + + +@router.get( + "/{job_id}", + response_model=GetJobResponse, + responses={ + 200: {"description": "Job retrieved", "model": GetJobResponse}, + 400: {"description": "Invalid job_id", "model": ErrorResponse}, + 401: {"description": "Unauthorized", "model": ErrorResponse}, + 404: {"description": "Job not found", "model": ErrorResponse}, + 500: {"description": "Internal error", "model": ErrorResponse}, + }, +) +async def get_job( + job_id: str, + client_id: ClientId = Depends(get_client_id), + correlation_id: CorrelationId = Depends(get_correlation_id), +) -> GetJobResponse: + """Return a job if it exists for the requesting client.""" + logger.info( + "Get job request: job_id=%s, client_id=%s, correlation_id=%s", + job_id, + client_id.value, + correlation_id.value, + ) + + try: + validated_job_id = JobId(job_id) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=_build_error_response( + "INVALID_JOB_ID", + f"Invalid job_id format: {job_id}", + correlation_id.value, + ).model_dump(), + ) from e + + job_repo = get_job_repo() + stage_repo = get_stage_repo() + + try: + job = job_repo.find_by_id(validated_job_id) # pylint: disable=no-member + if job is None or job.tombstoned: + raise JobNotFoundError(job_id, correlation_id.value) + + if job.client_id != client_id: + raise JobNotFoundError(job_id, correlation_id.value) + + stages_entities = stage_repo.find_all_by_job(validated_job_id) # pylint: disable=no-member + stages = [ + StageResponse( + stage_name=str(s.stage_name), + stage_state=s.stage_state.value, + started_at=s.started_at.isoformat() + "Z" if s.started_at else None, + ended_at=s.ended_at.isoformat() + "Z" if s.ended_at else None, + error_code=s.error_code, + error_summary=s.error_summary, + ) + for s in stages_entities + ] + return GetJobResponse( + job_id=str(job.job_id), + correlation_id=correlation_id.value, + job_state=job.job_state.value, + created_at=job.created_at.isoformat() + "Z", + updated_at=job.updated_at.isoformat() + "Z", + tombstone=job.tombstoned, + stages=stages, + ) + + except JobNotFoundError as e: + logger.warning("Job not found: %s", job_id) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=_build_error_response( + "JOB_NOT_FOUND", + e.message, + correlation_id.value, + ).model_dump(), + ) from e + + except Exception as e: + logger.exception("Unexpected error retrieving job") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=_build_error_response( + "INTERNAL_ERROR", + "An unexpected error occurred", + correlation_id.value, + ).model_dump(), + ) from e + + +@router.delete( + "/{job_id}", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + 204: {"description": "Job deleted successfully"}, + 400: {"description": "Invalid job_id", "model": ErrorResponse}, + 401: {"description": "Unauthorized", "model": ErrorResponse}, + 404: {"description": "Job not found", "model": ErrorResponse}, + 500: {"description": "Internal error", "model": ErrorResponse}, + }, +) +async def delete_job( + job_id: str, + client_id: ClientId = Depends(get_client_id), + correlation_id: CorrelationId = Depends(get_correlation_id), +) -> None: + """Delete (tombstone) a job for the requesting client if it exists.""" + logger.info( + "Delete job request: job_id=%s, client_id=%s, correlation_id=%s", + job_id, + client_id.value, + correlation_id.value, + ) + + try: + validated_job_id = JobId(job_id) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=_build_error_response( + "INVALID_JOB_ID", + f"Invalid job_id format: {job_id}", + correlation_id.value, + ).model_dump(), + ) from e + + from .dependencies import ( # pylint: disable=import-outside-toplevel + get_job_repo as _get_job_repo, + get_stage_repo as _get_stage_repo, + ) + job_repo_instance = _get_job_repo() + stage_repo_instance = _get_stage_repo() + + try: + job = job_repo_instance.find_by_id(validated_job_id) # pylint: disable=no-member + if job is None: + raise JobNotFoundError(job_id, correlation_id.value) + + if job.client_id != client_id: + raise JobNotFoundError(job_id, correlation_id.value) + + job.tombstone() + job_repo_instance.save(job) # pylint: disable=no-member + + stages_entities = stage_repo_instance.find_all_by_job(validated_job_id) # pylint: disable=no-member + for stage in stages_entities: + if not stage.stage_state.is_terminal(): + stage.cancel() + stage_repo_instance.save(stage) # pylint: disable=no-member + + except JobNotFoundError as e: + logger.warning("Job not found: %s", job_id) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=_build_error_response( + "JOB_NOT_FOUND", + e.message, + correlation_id.value, + ).model_dump(), + ) from e + + except InvalidStateTransitionError as e: + logger.warning("Invalid state transition occurred") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=_build_error_response( + "INVALID_STATE_TRANSITION", + e.message, + correlation_id.value, + ).model_dump(), + ) from e + + except Exception as e: + logger.exception("Unexpected error deleting job") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=_build_error_response( + "INTERNAL_ERROR", + "An unexpected error occurred", + correlation_id.value, + ).model_dump(), + ) from e diff --git a/build_stream/api/jobs/schemas.py b/build_stream/api/jobs/schemas.py new file mode 100644 index 0000000000..146dc8bd7b --- /dev/null +++ b/build_stream/api/jobs/schemas.py @@ -0,0 +1,115 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pydantic schemas for Jobs API requests and responses.""" + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field, field_validator + + +class CreateJobRequest(BaseModel): + """Request payload for creating a job.""" + + client_id: str = Field( + ..., + min_length=1, + max_length=255, + description="Client identifier", + ) + client_name: Optional[str] = Field( + default=None, + min_length=1, + max_length=255, + description="Optional client name", + ) + metadata: Optional[Dict[str, Any]] = Field( + default=None, + description="Optional metadata describing the job", + ) + parameters: Optional[Dict[str, Any]] = Field( + default=None, + description="Additional parameters for job execution", + ) + + model_config = {"populate_by_name": True} + + @field_validator("client_id") + @classmethod + def validate_client_id(cls, v: str) -> str: + """Validate client_id.""" + if not v.strip(): + raise ValueError("client_id cannot be empty") + return v.strip() + + @field_validator("client_name") + @classmethod + def validate_client_name(cls, v: Optional[str]) -> Optional[str]: + """Validate client name when provided.""" + if v is None: + return None + if not v.strip(): + raise ValueError("client_name cannot be empty") + return v.strip() + + +class StageResponse(BaseModel): + """Response model for a stage entry.""" + stage_name: str = Field(..., description="Stage identifier") + stage_state: str = Field(..., description="Stage state") + started_at: Optional[str] = Field(default=None, description="Start timestamp (ISO 8601)") + ended_at: Optional[str] = Field(default=None, description="End timestamp (ISO 8601)") + error_code: Optional[str] = Field(default=None, description="Error code if failed") + error_summary: Optional[str] = Field(default=None, description="Error summary if failed") + + +class CreateJobResponse(BaseModel): + """Response model for job creation.""" + job_id: str = Field(..., description="Job identifier") + correlation_id: str = Field(..., description="Correlation identifier") + job_state: str = Field(..., description="Job state") + created_at: str = Field(..., description="Creation timestamp (ISO 8601)") + stages: List[StageResponse] = Field(..., description="Job stages") + + +class GetJobResponse(BaseModel): + """Response model for retrieving a job.""" + job_id: str = Field(..., description="Job identifier") + correlation_id: str = Field(..., description="Correlation identifier") + job_state: str = Field(..., description="Job state") + created_at: str = Field(..., description="Creation timestamp (ISO 8601)") + updated_at: Optional[str] = Field( + default=None, description="Update timestamp (ISO 8601)" + ) + tombstone: Optional[bool] = Field(default=None, description="Tombstone flag") + stages: List[StageResponse] = Field(..., description="Job stages") + + +class ErrorResponse(BaseModel): + """Standard error response body.""" + error: str = Field(..., description="Error code") + message: str = Field(..., description="Error message") + correlation_id: str = Field(..., description="Request correlation ID") + timestamp: str = Field(..., description="Error timestamp (ISO 8601)") + + @classmethod + def create(cls, error: str, message: str, correlation_id: str) -> "ErrorResponse": + """Convenience constructor with current UTC timestamp.""" + return cls( + error=error, + message=message, + correlation_id=correlation_id, + timestamp=datetime.utcnow().isoformat() + "Z", + ) diff --git a/build_stream/api/parse_catalog/service.py b/build_stream/api/parse_catalog/service.py index d7fb5af862..38f07fd79b 100644 --- a/build_stream/api/parse_catalog/service.py +++ b/build_stream/api/parse_catalog/service.py @@ -21,7 +21,7 @@ from dataclasses import dataclass from typing import Optional -from core.catalog.generator import generate_root_json_from_catalog +from build_stream.core.catalog.generator import generate_root_json_from_catalog logger = logging.getLogger(__name__) diff --git a/build_stream/api/router.py b/build_stream/api/router.py index 4990a34e69..73be7fa415 100644 --- a/build_stream/api/router.py +++ b/build_stream/api/router.py @@ -17,9 +17,11 @@ from fastapi import APIRouter from .auth import router as auth_router +from .jobs import router as jobs_router from .parse_catalog import router as parse_catalog_router api_router = APIRouter(prefix="/api/v1") api_router.include_router(auth_router) api_router.include_router(parse_catalog_router) +api_router.include_router(jobs_router) diff --git a/build_stream/container.py b/build_stream/container.py new file mode 100644 index 0000000000..f8574e4863 --- /dev/null +++ b/build_stream/container.py @@ -0,0 +1,147 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dependency Injector containers for the Jobs API.""" +# pylint: disable=c-extension-no-member + +import os + +from dependency_injector import containers, providers + +from build_stream.infra.id_generator import JobUUIDGenerator, UUIDv4Generator +from build_stream.infra.repositories import ( + InMemoryJobRepository, + InMemoryStageRepository, + InMemoryIdempotencyRepository, + InMemoryAuditEventRepository, +) +from build_stream.orchestrator.jobs.use_cases import CreateJobUseCase + + +class DevContainer(containers.DeclarativeContainer): # pylint: disable=R0903 + """Development profile container. + + Uses in-memory mock repositories for fast development and testing. + No external dependencies (database, S3, etc.) required. + + Activated when ENV=dev (default). + """ + + wiring_config = containers.WiringConfiguration( + modules=[ + "build_stream.api.jobs.routes", + "build_stream.api.jobs.dependencies", + ] + ) + + job_id_generator = providers.Singleton(JobUUIDGenerator) + uuid_generator = providers.Singleton(UUIDv4Generator) + + job_repository = providers.Singleton(InMemoryJobRepository) + + stage_repository = providers.Singleton(InMemoryStageRepository) + + idempotency_repository = providers.Singleton(InMemoryIdempotencyRepository) + + audit_repository = providers.Singleton(InMemoryAuditEventRepository) + + create_job_use_case = providers.Factory( + CreateJobUseCase, + job_repo=job_repository, + stage_repo=stage_repository, + idempotency_repo=idempotency_repository, + audit_repo=audit_repository, + job_id_generator=job_id_generator, + uuid_generator=uuid_generator, + ) + + +class ProdContainer(containers.DeclarativeContainer): # pylint: disable=R0903 + """Production profile container. + + Currently uses mock repositories (same as dev). + TODO: Replace with PostgreSQL repositories when SQL implementation is ready. + + Activated when ENV=prod. + """ + + wiring_config = containers.WiringConfiguration( + modules=[ + "build_stream.api.jobs.routes", + "build_stream.api.jobs.dependencies", + ] + ) + + job_id_generator = providers.Singleton(JobUUIDGenerator) + uuid_generator = providers.Singleton(UUIDv4Generator) + + job_repository = providers.Singleton(InMemoryJobRepository) + + stage_repository = providers.Singleton(InMemoryStageRepository) + + idempotency_repository = providers.Singleton(InMemoryIdempotencyRepository) + + audit_repository = providers.Singleton(InMemoryAuditEventRepository) + + create_job_use_case = providers.Factory( + CreateJobUseCase, + job_repo=job_repository, + stage_repo=stage_repository, + idempotency_repo=idempotency_repository, + audit_repo=audit_repository, + job_id_generator=job_id_generator, + uuid_generator=uuid_generator, + ) + + +def get_container_class(): + """Select container class based on ENV environment variable. + + Returns: + DevContainer if ENV=dev (default) + ProdContainer if ENV=prod + + Usage: + # Set environment variable before running + ENV=prod python main.py + + # Or set in code before importing + os.environ['ENV'] = 'prod' + + # Or set in shell + export ENV=prod + python main.py + + # Windows PowerShell + $env:ENV = "prod" + python main.py + + # Windows Command Prompt + set ENV=prod + python main.py + """ + env = os.getenv("ENV", "dev").lower() + + if env == "prod": + return ProdContainer + + return DevContainer + + +Container = get_container_class() + +# Singleton container instance shared across app and dependencies +container = Container() + +__all__ = ["Container", "container", "get_container_class"] diff --git a/build_stream/core/jobs/entities/job.py b/build_stream/core/jobs/entities/job.py index 7ecf16b12e..5b0530106e 100644 --- a/build_stream/core/jobs/entities/job.py +++ b/build_stream/core/jobs/entities/job.py @@ -31,9 +31,10 @@ class Job: Attributes: job_id: Unique job identifier. - client_id: Client who owns this job. + client_id: Client who owns this job (from auth). + request_client_id: Client ID from request payload. job_state: Current lifecycle state. - catalog_digest: SHA-256 digest of catalog used. + client_name: Optional client name. created_at: Job creation timestamp. updated_at: Last modification timestamp. version: Optimistic locking version. @@ -42,7 +43,8 @@ class Job: job_id: JobId client_id: ClientId - catalog_digest: str + request_client_id: str + client_name: Optional[str] = None job_state: JobState = JobState.CREATED created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/build_stream/core/jobs/value_objects.py b/build_stream/core/jobs/value_objects.py index 9c95eca29d..b871751c16 100644 --- a/build_stream/core/jobs/value_objects.py +++ b/build_stream/core/jobs/value_objects.py @@ -17,6 +17,7 @@ All value objects are immutable and defined by their values, not identity. """ +import uuid import re from dataclasses import dataclass from enum import Enum @@ -25,31 +26,32 @@ @dataclass(frozen=True) class JobId: - """UUID v7 identifier for a job. + """UUID identifier for a job. Attributes: - value: String representation of UUID v7. + value: String representation of UUID. Raises: - ValueError: If value does not match UUID v7 pattern or exceeds length. + ValueError: If value does not match UUID format or exceeds length. """ value: str - UUID_V7_PATTERN: ClassVar[str] = ( - r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' - ) - MAX_LENGTH: ClassVar[int] = 36 # UUID v7 standard length + MAX_LENGTH: ClassVar[int] = 36 # UUID standard length def __post_init__(self) -> None: - """Validate UUID v7 format and length.""" + """Validate UUID format and length.""" if len(self.value) > self.MAX_LENGTH: raise ValueError( f"JobId length cannot exceed {self.MAX_LENGTH} characters, " f"got {len(self.value)}" ) - if not re.match(self.UUID_V7_PATTERN, self.value.lower()): - raise ValueError(f"Invalid UUID v7 format: {self.value}") + try: + uuid_obj = uuid.UUID(self.value) + except Exception as exc: + raise ValueError(f"Invalid UUID format: {self.value}") from exc + # normalize representation + object.__setattr__(self, "value", str(uuid_obj)) def __str__(self) -> str: """Return string representation.""" @@ -58,31 +60,31 @@ def __str__(self) -> str: @dataclass(frozen=True) class CorrelationId: - """UUID v7 identifier for request tracing. + """UUID identifier for request tracing. Attributes: - value: String representation of UUID v7. + value: String representation of UUID. Raises: - ValueError: If value does not match UUID v7 pattern or exceeds length. + ValueError: If value does not match UUID format or exceeds length. """ value: str - UUID_V7_PATTERN: ClassVar[str] = ( - r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' - ) - MAX_LENGTH: ClassVar[int] = 36 # UUID v7 standard length + MAX_LENGTH: ClassVar[int] = 36 # UUID standard length def __post_init__(self) -> None: - """Validate UUID v7 format and length.""" + """Validate UUID format and length.""" if len(self.value) > self.MAX_LENGTH: raise ValueError( f"CorrelationId length cannot exceed {self.MAX_LENGTH} characters, " f"got {len(self.value)}" ) - if not re.match(self.UUID_V7_PATTERN, self.value.lower()): - raise ValueError(f"Invalid UUID v7 format: {self.value}") + try: + uuid_obj = uuid.UUID(self.value) + except Exception as exc: + raise ValueError(f"Invalid UUID format: {self.value}") from exc + object.__setattr__(self, "value", str(uuid_obj)) def __str__(self) -> str: """Return string representation.""" diff --git a/build_stream/infra/id_generator.py b/build_stream/infra/id_generator.py index 5770039942..dec1878b86 100644 --- a/build_stream/infra/id_generator.py +++ b/build_stream/infra/id_generator.py @@ -12,75 +12,29 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Infrastructure layer for JobId generation. +"""Infrastructure layer for JobId/UUID generation using UUID v4.""" -This module provides UUID v7 generation for JobId creation. -TODO: Replace with uuid7 library when available in standard library. -""" - -import time import uuid from build_stream.core.jobs.exceptions import JobDomainError from build_stream.core.jobs.repositories import JobIdGenerator, UUIDGenerator from build_stream.core.jobs.value_objects import JobId -class UUIDv7Generator(JobIdGenerator): - """Temporary UUID v7 generator implementation. - This is a fallback implementation until uuid7 is available - in the Python standard library. Generates time-ordered UUIDs - compatible with UUID v7 specification. - """ +class JobUUIDGenerator(JobIdGenerator): # pylint: disable=R0903 + """JobId generator using UUID v4.""" def generate(self) -> JobId: - """Generate a new UUID v7 JobId. - - Returns: - JobId: A new UUID v7 identifier. - - Raises: - JobDomainError: If JobId generation fails. - """ try: - return JobId(str(self._uuid7())) + return JobId(str(uuid.uuid4())) except ValueError: raise except Exception as exc: raise JobDomainError(f"Failed to generate JobId: {exc}") from exc - def _uuid7(self) -> uuid.UUID: - """Generate a UUID v7 using timestamp and random bytes. - - Returns: - uuid.UUID: A UUID v7 object. - """ - timestamp_ms = int(time.time() * 1000) - timestamp_bytes = timestamp_ms.to_bytes(6, byteorder='big') - - random_bytes = uuid.uuid4().bytes - - uuid7_bytes = bytearray(16) - uuid7_bytes[:6] = timestamp_bytes - uuid7_bytes[6:] = random_bytes[6:] - uuid7_bytes[6] = (0x07 << 4) | (uuid7_bytes[6] & 0x0f) - uuid7_bytes[8] = 0x80 | (uuid7_bytes[8] & 0x3f) - - return uuid.UUID(bytes=bytes(uuid7_bytes)) - - -class UUIDv4Generator(UUIDGenerator): - """UUID v4 generator for general purpose use. - - Generates random UUID v4 identifiers that can be used for events, - correlation IDs, or any other purpose requiring a unique identifier. - """ +class UUIDv4Generator(UUIDGenerator): # pylint: disable=R0903 + """UUID v4 generator for general purpose use (returns uuid.UUID).""" def generate(self) -> uuid.UUID: - """Generate a new UUID v4. - - Returns: - uuid.UUID: A new UUID v4 object. - """ return uuid.uuid4() diff --git a/build_stream/infra/repositories/__init__.py b/build_stream/infra/repositories/__init__.py new file mode 100644 index 0000000000..8b04dad71e --- /dev/null +++ b/build_stream/infra/repositories/__init__.py @@ -0,0 +1,27 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .in_memory import ( + InMemoryJobRepository, + InMemoryStageRepository, + InMemoryIdempotencyRepository, + InMemoryAuditEventRepository, +) + +__all__ = [ + "InMemoryJobRepository", + "InMemoryStageRepository", + "InMemoryIdempotencyRepository", + "InMemoryAuditEventRepository", +] diff --git a/build_stream/infra/repositories/in_memory.py b/build_stream/infra/repositories/in_memory.py new file mode 100644 index 0000000000..3a3fa02c85 --- /dev/null +++ b/build_stream/infra/repositories/in_memory.py @@ -0,0 +1,120 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" This file contains in-memory implementations of the job repository. + It is used in testing and development.""" + +from typing import Dict, List, Optional + +from build_stream.core.jobs.entities import Job, Stage, IdempotencyRecord, AuditEvent +from build_stream.core.jobs.value_objects import JobId, IdempotencyKey, StageName + +class InMemoryJobRepository: + """In-memory implementation of Job repository for testing.""" + + def __init__(self) -> None: + """Initialize the repository with empty job storage.""" + self._jobs: Dict[str, Job] = {} + + def save(self, job: Job) -> None: + """Save a job to the in-memory storage.""" + self._jobs[str(job.job_id)] = job + + def find_by_id(self, job_id: JobId) -> Optional[Job]: + """Find a job by its ID.""" + return self._jobs.get(str(job_id)) + + def exists(self, job_id: JobId) -> bool: + """Check if a job exists by its ID.""" + return str(job_id) in self._jobs + + +class InMemoryStageRepository: + """In-memory implementation of Stage repository for testing.""" + + def __init__(self) -> None: + """Initialize the repository with empty stage storage.""" + self._stages: Dict[str, List[Stage]] = {} + + def save(self, stage: Stage) -> None: + """Save a stage to the in-memory storage.""" + job_key = str(stage.job_id) + if job_key not in self._stages: + self._stages[job_key] = [] + + existing = self.find_by_job_and_name(stage.job_id, stage.stage_name) + if existing: + stages = self._stages[job_key] + self._stages[job_key] = [ + s for s in stages if str(s.stage_name) != str(stage.stage_name) + ] + + self._stages[job_key].append(stage) + + def save_all(self, stages: List[Stage]) -> None: + """Save multiple stages to the in-memory storage.""" + for stage in stages: + self.save(stage) + + def find_by_job_and_name( + self, job_id: JobId, stage_name: StageName + ) -> Optional[Stage]: + """Find a stage by job ID and stage name.""" + job_key = str(job_id) + if job_key not in self._stages: + return None + + for stage in self._stages[job_key]: + if str(stage.stage_name) == str(stage_name): + return stage + return None + + def find_all_by_job(self, job_id: JobId) -> List[Stage]: + """Find all stages for a given job ID.""" + return self._stages.get(str(job_id), []) + + +class InMemoryIdempotencyRepository: + """In-memory implementation of Idempotency repository for testing.""" + + def __init__(self) -> None: + """Initialize the repository with empty idempotency storage.""" + self._records: Dict[str, IdempotencyRecord] = {} + + def save(self, record: IdempotencyRecord) -> None: + """Save an idempotency record to the in-memory storage.""" + self._records[str(record.idempotency_key)] = record + + def find_by_key(self, key: IdempotencyKey) -> Optional[IdempotencyRecord]: + """Find an idempotency record by its key.""" + return self._records.get(str(key)) + + +class InMemoryAuditEventRepository: + """In-memory implementation of AuditEvent repository for testing.""" + + def __init__(self) -> None: + """Initialize the repository with empty audit event storage.""" + self._events: Dict[str, List[AuditEvent]] = {} + + def save(self, event: AuditEvent) -> None: + """Save an audit event to the in-memory storage.""" + job_key = str(event.job_id) + if job_key not in self._events: + self._events[job_key] = [] + self._events[job_key].append(event) + + def find_by_job(self, job_id: JobId) -> List[AuditEvent]: + """Find all audit events for a given job ID.""" + return self._events.get(str(job_id), []) diff --git a/build_stream/main.py b/build_stream/main.py index 9187500d73..65e124ec28 100644 --- a/build_stream/main.py +++ b/build_stream/main.py @@ -28,7 +28,8 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from api.router import api_router +from build_stream.api.router import api_router +from build_stream.container import container LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() logging.basicConfig( @@ -37,6 +38,9 @@ ) logger = logging.getLogger(__name__) +container.wire(modules=["build_stream.api.jobs.routes", "build_stream.api.jobs.dependencies"]) +logger.info("Using container: %s", container.__class__.__name__) + app = FastAPI( title="Build Stream API", description="RESTful API for the Omnia Build Stream application", @@ -46,6 +50,9 @@ openapi_url="/openapi.json", ) +# Attach container to app so dependency_injector Provide dependencies resolve +app.container = container + app.add_middleware( CORSMiddleware, allow_origins=os.getenv("CORS_ORIGINS", "*").split(","), diff --git a/build_stream/orchestrator/jobs/commands/create_job.py b/build_stream/orchestrator/jobs/commands/create_job.py index 684bc6a4cd..4fac339d2b 100644 --- a/build_stream/orchestrator/jobs/commands/create_job.py +++ b/build_stream/orchestrator/jobs/commands/create_job.py @@ -31,13 +31,15 @@ class CreateJobCommand: All validation is performed in the use case layer. Attributes: - client_id: Client who owns this job. - catalog_digest: SHA-256 digest of catalog used. + client_id: Client who owns this job (from auth). + request_client_id: Client ID from request payload. + client_name: Optional client name. correlation_id: Request correlation identifier for tracing. idempotency_key: Client-supplied key for retry deduplication. """ client_id: ClientId - catalog_digest: str + request_client_id: str correlation_id: CorrelationId idempotency_key: IdempotencyKey + client_name: str | None = None diff --git a/build_stream/orchestrator/jobs/dtos/job_response.py b/build_stream/orchestrator/jobs/dtos/job_response.py index 8cfc6d57f8..8e2befcd56 100644 --- a/build_stream/orchestrator/jobs/dtos/job_response.py +++ b/build_stream/orchestrator/jobs/dtos/job_response.py @@ -15,6 +15,7 @@ """Job response DTO.""" from dataclasses import dataclass +from typing import Optional @dataclass(frozen=True) @@ -38,7 +39,8 @@ class JobResponse: job_id: str client_id: str - catalog_digest: str + request_client_id: str + client_name: Optional[str] job_state: str created_at: str updated_at: str @@ -60,7 +62,8 @@ def from_entity(job, is_new: bool = True) -> "JobResponse": return JobResponse( job_id=str(job.job_id), client_id=str(job.client_id), - catalog_digest=job.catalog_digest, + request_client_id=job.request_client_id, + client_name=job.client_name, job_state=job.job_state.value, created_at=job.created_at.isoformat(), updated_at=job.updated_at.isoformat(), diff --git a/build_stream/orchestrator/jobs/use_cases/create_job.py b/build_stream/orchestrator/jobs/use_cases/create_job.py index e5773d886d..bb71b18026 100644 --- a/build_stream/orchestrator/jobs/use_cases/create_job.py +++ b/build_stream/orchestrator/jobs/use_cases/create_job.py @@ -142,7 +142,8 @@ def _build_job(self, command: CreateJobCommand, job_id: JobId) -> Job: return Job( job_id=job_id, client_id=command.client_id, - catalog_digest=command.catalog_digest, + request_client_id=command.request_client_id, + client_name=command.client_name, ) def _save_job_and_stages(self, job: Job, stages: List[Stage]) -> None: @@ -183,7 +184,7 @@ def _emit_job_created_event( client_id=command.client_id, timestamp=self._now_utc(), details={ - "catalog_digest": command.catalog_digest, + "client_name": command.client_name, "stage_count": len(stages), }, ) @@ -201,9 +202,9 @@ def _compute_fingerprint(self, command: CreateJobCommand) -> RequestFingerprint: """Compute request fingerprint for idempotency. Fingerprint includes only request payload, not auth-derived fields.""" - request_body = { - "catalog_digest": command.catalog_digest, - } + request_body = {} + if command.client_name: + request_body["client_name"] = command.client_name return FingerprintService.compute(request_body) def _create_initial_stages(self, job_id: JobId) -> List[Stage]: diff --git a/build_stream/tests/README.md b/build_stream/tests/README.md new file mode 100644 index 0000000000..47d5270ec7 --- /dev/null +++ b/build_stream/tests/README.md @@ -0,0 +1,172 @@ +# Test Suite for JobId Feature + +This directory contains the unit and integration tests for the Jobs API (JobId, correlation/idempotency, CRUD flows). + +## Test Structure + +``` +tests/ +├── integration/ +│ └── api/ +│ └── jobs/ +│ ├── conftest.py # Shared fixtures +│ ├── test_create_job_api.py # POST /jobs tests +│ ├── test_get_job_api.py # GET /jobs/{id} tests +│ └── test_delete_job_api.py # DELETE /jobs/{id} tests +└── unit/ + └── api/ + └── jobs/ + ├── test_schemas.py # Pydantic schema tests + └── test_dependencies.py # Dependency injection tests +``` + +## Prerequisites + +Install test dependencies: + +```bash +pip install -r requirements.txt +``` + +Required packages: +- pytest>=7.4.0 +- pytest-asyncio>=0.21.0 +- httpx>=0.24.0 +- pytest-cov>=4.1.0 + +## Running Tests + +### Run All Tests + +```bash +# Run all tests +pytest tests/ -v + +# Run with coverage +pytest tests/ --cov=api --cov=orchestrator --cov-report=html +``` + +### Run Specific Test Suites + +```bash +# Integration tests only +pytest tests/integration/ -v + +# Unit tests only +pytest tests/unit/ -v + +# API tests only +pytest tests/integration/api/ tests/unit/api/ -v +``` + +### Run Specific Test Files + +```bash +# Create Job API tests +pytest tests/integration/api/jobs/test_create_job_api.py -v + +# Schema validation tests +pytest tests/unit/api/jobs/test_schemas.py -v + +# Dependency injection tests +pytest tests/unit/api/jobs/test_dependencies.py -v +``` + +### Run Specific Test Classes or Functions + +```bash +# Run specific test class +pytest tests/integration/api/jobs/test_create_job_api.py::TestCreateJobSuccess -v + +# Run specific test function +pytest tests/integration/api/jobs/test_create_job_api.py::TestCreateJobSuccess::test_create_job_returns_201_with_valid_request -v + +# Run tests matching pattern +pytest tests/integration/ -k idempotency -v +``` + +## Test Fixtures + +### Shared Fixtures (conftest.py) + +- `client`: FastAPI TestClient with dev container +- `auth_headers`: Standard authentication headers +- `unique_idempotency_key`: Unique key per test +- `unique_correlation_id`: Unique correlation ID per test + +### Usage Example + +```python +def test_example(client, auth_headers): + payload = {"catalog_uri": "s3://bucket/catalog.json"} + response = client.post("/api/v1/jobs", json=payload, headers=auth_headers) + assert response.status_code == 201 +``` + +## Coverage Report + +Generate HTML coverage report: + +```bash +pytest tests/ --cov=api --cov=orchestrator --cov-report=html +``` + +View report: +```bash +# Open htmlcov/index.html in browser +``` + +## CI/CD Integration + +Add to GitHub Actions workflow: + +```yaml +- name: Run Tests + run: | + pip install -r requirements.txt + pytest tests/ --cov=api --cov=orchestrator --cov-report=xml + +- name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml +``` + +## Test Best Practices + +1. **Isolation**: Each test is independent (unique idempotency keys) +2. **Fast**: Integration tests complete in <5 seconds each +3. **Deterministic**: No flaky tests, no time-dependent logic +4. **Clear**: Descriptive test names following pattern `test___` +5. **Comprehensive**: Cover happy path, error cases, edge cases, and security + +## Troubleshooting + +### Tests Fail with "Module not found" + +```bash +# Ensure you're in the correct directory +cd build_stream/ + +# Run with Python path +PYTHONPATH=. pytest tests/ +``` + +### Tests Fail with Container Issues + +```bash +# Set ENV to dev +export ENV=dev # Linux/Mac +set ENV=dev # Windows CMD +$env:ENV = "dev" # Windows PowerShell + +pytest tests/ +``` + +### Slow Test Execution + +```bash +# Run tests in parallel +pip install pytest-xdist +pytest tests/ -n auto +``` diff --git a/build_stream/tests/integration/api/__init__.py b/build_stream/tests/integration/api/__init__.py new file mode 100644 index 0000000000..54b309aafe --- /dev/null +++ b/build_stream/tests/integration/api/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/build_stream/tests/integration/api/jobs/__init__.py b/build_stream/tests/integration/api/jobs/__init__.py new file mode 100644 index 0000000000..54b309aafe --- /dev/null +++ b/build_stream/tests/integration/api/jobs/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/build_stream/tests/integration/api/jobs/conftest.py b/build_stream/tests/integration/api/jobs/conftest.py new file mode 100644 index 0000000000..64b217ff26 --- /dev/null +++ b/build_stream/tests/integration/api/jobs/conftest.py @@ -0,0 +1,59 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared fixtures for Jobs API integration tests.""" + +import os +from typing import Dict + +import pytest +from fastapi.testclient import TestClient + +from build_stream.main import app +from build_stream.infra.id_generator import UUIDv4Generator + + +@pytest.fixture(scope="function") +def client(): + """Create test client with fresh container for each test.""" + os.environ["ENV"] = "dev" + return TestClient(app) + + +@pytest.fixture(name="uuid_generator") +def uuid_generator_fixture(): + """UUID generator for test fixtures.""" + return UUIDv4Generator() + + +@pytest.fixture +def auth_headers(uuid_generator) -> Dict[str, str]: + """Standard authentication headers for testing.""" + return { + "Authorization": "Bearer test-client-123", + "X-Correlation-Id": str(uuid_generator.generate()), + "Idempotency-Key": f"test-key-{uuid_generator.generate()}", + } + + +@pytest.fixture +def unique_idempotency_key(uuid_generator) -> str: + """Generate unique idempotency key for each test.""" + return f"test-key-{uuid_generator.generate()}" + + +@pytest.fixture +def unique_correlation_id(uuid_generator) -> str: + """Generate unique correlation ID for each test.""" + return str(uuid_generator.generate()) diff --git a/build_stream/tests/integration/api/jobs/test_create_job_api.py b/build_stream/tests/integration/api/jobs/test_create_job_api.py new file mode 100644 index 0000000000..fedba99f9c --- /dev/null +++ b/build_stream/tests/integration/api/jobs/test_create_job_api.py @@ -0,0 +1,310 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests for Jobs create API.""" +# pylint: disable=missing-function-docstring + +import uuid + +class TestCreateJobSuccess: + """Happy-path create job tests.""" + + def test_create_job_returns_201_with_valid_request(self, client, auth_headers): + payload = { + "client_id": "client-123", + "client_name": "test-client", + "metadata": {"description": "Test job creation"}, + } + + response = client.post("/api/v1/jobs", json=payload, headers=auth_headers) + + assert response.status_code == 201 + data = response.json() + assert "job_id" in data + assert "correlation_id" in data + assert "job_state" in data + assert "created_at" in data + assert "stages" in data + + def test_create_job_returns_valid_uuid(self, client, auth_headers): + payload = {"client_id": "client-123", "client_name": "test-client"} + + response = client.post("/api/v1/jobs", json=payload, headers=auth_headers) + + assert response.status_code == 201 + job_id = response.json()["job_id"] + + # Validate via uuid library to allow any standard UUID version + parsed = uuid.UUID(job_id) + assert str(parsed) == job_id.lower() + + def test_create_job_returns_created_state(self, client, auth_headers): + payload = {"client_id": "client-123", "client_name": "test-client"} + + response = client.post("/api/v1/jobs", json=payload, headers=auth_headers) + + assert response.status_code == 201 + assert response.json()["job_state"] == "CREATED" + + def test_create_job_creates_all_nine_stages(self, client, auth_headers): + payload = {"client_id": "client-123", "client_name": "test-client"} + + response = client.post("/api/v1/jobs", json=payload, headers=auth_headers) + + assert response.status_code == 201 + stages = response.json()["stages"] + assert len(stages) == 9 + + expected_stages = [ + "parse-catalog", + "generate-input-files", + "create-local-repository", + "update-local-repository", + "create-image-repository", + "build-image", + "validate-image", + "validate-image-on-test", + "promote" + ] + + stage_names = [s["stage_name"] for s in stages] + assert stage_names == expected_stages + + def test_create_job_all_stages_pending(self, client, auth_headers): + payload = {"client_id": "client-123", "client_name": "test-client"} + + response = client.post("/api/v1/jobs", json=payload, headers=auth_headers) + + assert response.status_code == 201 + stages = response.json()["stages"] + + for stage in stages: + assert stage["stage_state"] == "PENDING" + assert stage["started_at"] is None + assert stage["ended_at"] is None + assert stage["error_code"] is None + assert stage["error_summary"] is None + + def test_create_job_returns_correlation_id( + self, client, unique_correlation_id, unique_idempotency_key + ): + headers = { + "Authorization": "Bearer test-client-123", + "X-Correlation-Id": unique_correlation_id, + "Idempotency-Key": unique_idempotency_key, + } + payload = {"client_id": "client-123", "client_name": "test-client"} + + response = client.post("/api/v1/jobs", json=payload, headers=headers) + + assert response.status_code == 201 + assert response.json()["correlation_id"] == unique_correlation_id + + +class TestCreateJobIdempotency: + """Idempotency behavior tests for create job.""" + + def test_idempotent_request_returns_200_with_same_job( + self, client, unique_idempotency_key, unique_correlation_id + ): + headers = { + "Authorization": "Bearer test-client-123", + "X-Correlation-Id": unique_correlation_id, + "Idempotency-Key": unique_idempotency_key, + } + payload = {"client_id": "client-123", "client_name": "test-client"} + + response1 = client.post("/api/v1/jobs", json=payload, headers=headers) + assert response1.status_code == 201 + job_id_1 = response1.json()["job_id"] + + response2 = client.post("/api/v1/jobs", json=payload, headers=headers) + assert response2.status_code == 200 + job_id_2 = response2.json()["job_id"] + + assert job_id_1 == job_id_2 + + def test_idempotency_with_different_correlation_id( + self, client, unique_idempotency_key + ): + payload = {"client_id": "client-123", "client_name": "test-client"} + + headers1 = { + "Authorization": "Bearer test-client-123", + "X-Correlation-Id": "019bf590-1111-7890-abcd-ef1234567890", + "Idempotency-Key": unique_idempotency_key, + } + response1 = client.post("/api/v1/jobs", json=payload, headers=headers1) + assert response1.status_code == 201 + job_id_1 = response1.json()["job_id"] + + headers2 = { + "Authorization": "Bearer test-client-123", + "X-Correlation-Id": "019bf590-2222-7890-abcd-ef1234567890", + "Idempotency-Key": unique_idempotency_key, + } + response2 = client.post("/api/v1/jobs", json=payload, headers=headers2) + assert response2.status_code == 200 + job_id_2 = response2.json()["job_id"] + + assert job_id_1 == job_id_2 + + # def test_idempotency_conflict_different_payload( + # self, client, unique_idempotency_key, unique_correlation_id + # ): + # headers = { + # "Authorization": "Bearer test-client-123", + # "X-Correlation-Id": unique_correlation_id, + # "Idempotency-Key": unique_idempotency_key, + # } + # + # payload1 = {"client_name": "client-one"} + # response1 = client.post("/api/v1/jobs", json=payload1, headers=headers) + # assert response1.status_code == 201 + # + # payload2 = {"client_name": "client-two"} + # response2 = client.post("/api/v1/jobs", json=payload2, headers=headers) + # assert response2.status_code == 409 + # + # error_detail = response2.json()["detail"] + # assert "IDEMPOTENCY_CONFLICT" in error_detail["error"] + + +class TestCreateJobValidation: + """Validation scenarios for create job.""" + + def test_missing_client_id_returns_422(self, client, auth_headers): + """Missing client_id is required and should fail validation.""" + payload = {"client_name": "test-client"} + + response = client.post("/api/v1/jobs", json=payload, headers=auth_headers) + + assert response.status_code == 422 + + def test_missing_client_name_is_allowed(self, client, auth_headers): + """Missing client_name is allowed (field is optional).""" + payload = {"client_id": "client-123"} + + response = client.post("/api/v1/jobs", json=payload, headers=auth_headers) + + assert response.status_code in [200, 201] + + def test_empty_client_id_returns_422(self, client, auth_headers): + """Empty client_id should be rejected.""" + payload = {"client_id": ""} + + response = client.post("/api/v1/jobs", json=payload, headers=auth_headers) + + assert response.status_code in [400, 422] + + def test_empty_client_name_returns_400(self, client, auth_headers): + """Empty client_name should be rejected.""" + payload = {"client_id": "client-123", "client_name": ""} + + response = client.post("/api/v1/jobs", json=payload, headers=auth_headers) + + assert response.status_code in [400, 422] + + def test_client_id_whitespace_only_returns_422(self, client, auth_headers): + """Whitespace-only client_id should be rejected.""" + payload = {"client_id": " "} + + response = client.post("/api/v1/jobs", json=payload, headers=auth_headers) + + assert response.status_code in [400, 422] + + def test_client_name_whitespace_only_returns_400(self, client, auth_headers): + """Whitespace-only client_name should be rejected.""" + payload = {"client_id": "client-123", "client_name": " "} + + response = client.post("/api/v1/jobs", json=payload, headers=auth_headers) + + assert response.status_code in [400, 422] + + +class TestCreateJobAuthentication: + """Authentication header tests.""" + + def test_missing_authorization_header_returns_422(self, client, unique_idempotency_key): + """Auth header required.""" + headers = { + "X-Correlation-Id": "019bf590-1234-7890-abcd-ef1234567890", + "Idempotency-Key": unique_idempotency_key, + } + payload = {"client_id": "client-123", "client_name": "test-client"} + + response = client.post("/api/v1/jobs", json=payload, headers=headers) + + assert response.status_code == 422 + + def test_invalid_authorization_format_returns_401( + self, client, unique_idempotency_key + ): + """Invalid auth scheme returns 401.""" + headers = { + "Authorization": "InvalidFormat test-token", + "X-Correlation-Id": "019bf590-1234-7890-abcd-ef1234567890", + "Idempotency-Key": unique_idempotency_key, + } + payload = {"client_id": "client-123", "client_name": "test-client"} + + response = client.post("/api/v1/jobs", json=payload, headers=headers) + + assert response.status_code == 401 + + def test_empty_bearer_token_returns_401(self, client, unique_idempotency_key): + """Empty bearer token returns 401.""" + headers = { + "Authorization": "Bearer ", + "X-Correlation-Id": "019bf590-1234-7890-abcd-ef1234567890", + "Idempotency-Key": unique_idempotency_key, + } + payload = {"client_id": "client-123", "client_name": "test-client"} + + response = client.post("/api/v1/jobs", json=payload, headers=headers) + + assert response.status_code == 401 + + +class TestCreateJobHeaders: + """Header handling tests.""" + + def test_missing_idempotency_key_returns_422(self, client): + """Idempotency key is required.""" + headers = { + "Authorization": "Bearer test-client-123", + "X-Correlation-Id": "019bf590-1234-7890-abcd-ef1234567890", + } + payload = {"client_id": "client-123", "client_name": "test-client"} + + response = client.post("/api/v1/jobs", json=payload, headers=headers) + + assert response.status_code == 422 + + def test_auto_generates_correlation_id_if_missing( + self, client, unique_idempotency_key + ): + """Server should generate correlation ID when absent.""" + headers = { + "Authorization": "Bearer test-client-123", + "Idempotency-Key": unique_idempotency_key, + } + payload = {"client_id": "client-123", "client_name": "test-client"} + + response = client.post("/api/v1/jobs", json=payload, headers=headers) + + assert response.status_code == 201 + assert "correlation_id" in response.json() + correlation_id = response.json()["correlation_id"] + assert len(correlation_id) == 36 diff --git a/build_stream/tests/integration/api/jobs/test_delete_job_api.py b/build_stream/tests/integration/api/jobs/test_delete_job_api.py new file mode 100644 index 0000000000..766a78805e --- /dev/null +++ b/build_stream/tests/integration/api/jobs/test_delete_job_api.py @@ -0,0 +1,161 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests for DELETE job API endpoint.""" + +# pylint: disable=too-few-public-methods +# pylint: disable=duplicate-code + + + +class TestDeleteJobSuccess: + """Tests for successful job deletion scenarios.""" + + def test_delete_existing_job_returns_204(self, client, auth_headers): + """Delete existing job should return 204 No Content.""" + create_payload = {"client_id": "client-123", "client_name": "test-client"} + create_response = client.post("/api/v1/jobs", json=create_payload, headers=auth_headers) + assert create_response.status_code == 201 + job_id = create_response.json()["job_id"] + + delete_headers = { + "Authorization": auth_headers["Authorization"], + "X-Correlation-Id": auth_headers["X-Correlation-Id"], + } + delete_response = client.delete(f"/api/v1/jobs/{job_id}", headers=delete_headers) + + assert delete_response.status_code == 204 + assert delete_response.content == b"" + + def test_delete_job_is_idempotent(self, client, auth_headers): + """Delete job should be idempotent - multiple deletes should succeed.""" + create_payload = {"client_id": "client-123", "client_name": "test-client"} + create_response = client.post("/api/v1/jobs", json=create_payload, headers=auth_headers) + job_id = create_response.json()["job_id"] + + delete_headers = { + "Authorization": auth_headers["Authorization"], + "X-Correlation-Id": auth_headers["X-Correlation-Id"], + } + + delete_response1 = client.delete(f"/api/v1/jobs/{job_id}", headers=delete_headers) + assert delete_response1.status_code == 204 + + delete_response2 = client.delete(f"/api/v1/jobs/{job_id}", headers=delete_headers) + assert delete_response2.status_code in [204, 404, 410] + + def test_deleted_job_not_retrievable(self, client, auth_headers): + """Deleted job should not be retrievable via GET endpoint.""" + create_payload = {"client_id": "client-123", "client_name": "test-client"} + create_response = client.post("/api/v1/jobs", json=create_payload, headers=auth_headers) + job_id = create_response.json()["job_id"] + + headers = { + "Authorization": auth_headers["Authorization"], + "X-Correlation-Id": auth_headers["X-Correlation-Id"], + } + + delete_response = client.delete(f"/api/v1/jobs/{job_id}", headers=headers) + assert delete_response.status_code == 204 + + get_response = client.get(f"/api/v1/jobs/{job_id}", headers=headers) + assert get_response.status_code in [404, 410] + + +class TestDeleteJobNotFound: + """Tests for job deletion when job doesn't exist.""" + + def test_delete_nonexistent_job_returns_404(self, client, auth_headers): + """Delete nonexistent job should return 404 Not Found.""" + nonexistent_job_id = "019bf590-1234-7890-abcd-ef1234567890" + + delete_headers = { + "Authorization": auth_headers["Authorization"], + "X-Correlation-Id": auth_headers["X-Correlation-Id"], + } + response = client.delete(f"/api/v1/jobs/{nonexistent_job_id}", headers=delete_headers) + + assert response.status_code == 404 + + def test_delete_job_invalid_uuid_format_returns_400(self, client, auth_headers): + """Delete job with invalid UUID format should return 400 Bad Request.""" + invalid_job_id = "not-a-valid-uuid" + + delete_headers = { + "Authorization": auth_headers["Authorization"], + "X-Correlation-Id": auth_headers["X-Correlation-Id"], + } + response = client.delete(f"/api/v1/jobs/{invalid_job_id}", headers=delete_headers) + + assert response.status_code == 400 + + +class TestDeleteJobAuthentication: + """Tests for authentication in job deletion.""" + + def test_delete_job_missing_authorization_returns_422(self, client, unique_correlation_id): + """Delete job without auth header should return 422 Unprocessable Entity.""" + job_id = "019bf590-1234-7890-abcd-ef1234567890" + headers = {"X-Correlation-Id": unique_correlation_id} + + response = client.delete(f"/api/v1/jobs/{job_id}", headers=headers) + + assert response.status_code == 422 + + def test_delete_job_invalid_auth_format_returns_401( + self, client, unique_correlation_id + ): + """Delete job with invalid auth format should return 401 Unauthorized.""" + job_id = "019bf590-1234-7890-abcd-ef1234567890" + headers = { + "Authorization": "InvalidFormat test-token", + "X-Correlation-Id": unique_correlation_id, + } + + response = client.delete(f"/api/v1/jobs/{job_id}", headers=headers) + + assert response.status_code == 401 + + +class TestDeleteJobClientIsolation: + """Tests for client isolation in job deletion.""" + + def test_different_client_cannot_delete_job( + self, client, unique_idempotency_key, unique_correlation_id + ): + """Different client should not be able to delete another client's job.""" + create_headers = { + "Authorization": "Bearer client-a", + "X-Correlation-Id": unique_correlation_id, + "Idempotency-Key": unique_idempotency_key, + } + create_payload = {"client_id": "client-123", "client_name": "test-client"} + create_response = client.post("/api/v1/jobs", json=create_payload, headers=create_headers) + assert create_response.status_code == 201 + job_id = create_response.json()["job_id"] + + delete_headers = { + "Authorization": "Bearer client-b", + "X-Correlation-Id": unique_correlation_id, + } + delete_response = client.delete(f"/api/v1/jobs/{job_id}", headers=delete_headers) + + assert delete_response.status_code in [403, 404] + + verify_headers = { + "Authorization": "Bearer client-a", + "X-Correlation-Id": unique_correlation_id, + } + verify_response = client.get(f"/api/v1/jobs/{job_id}", headers=verify_headers) + assert verify_response.status_code == 200 diff --git a/build_stream/tests/integration/api/jobs/test_get_job_api.py b/build_stream/tests/integration/api/jobs/test_get_job_api.py new file mode 100644 index 0000000000..ec3b169cb9 --- /dev/null +++ b/build_stream/tests/integration/api/jobs/test_get_job_api.py @@ -0,0 +1,153 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests for GET job API endpoint.""" + +# pylint: disable=too-few-public-methods +# pylint: disable=duplicate-code + + + +class TestGetJobSuccess: + """Tests for successful job retrieval scenarios.""" + + def test_get_existing_job_returns_200(self, client, auth_headers): + """Get existing job should return 200 OK with job details.""" + create_payload = {"client_id": "client-123", "client_name": "test-client"} + create_response = client.post("/api/v1/jobs", json=create_payload, headers=auth_headers) + assert create_response.status_code == 201 + job_id = create_response.json()["job_id"] + + get_headers = { + "Authorization": auth_headers["Authorization"], + "X-Correlation-Id": auth_headers["X-Correlation-Id"], + } + get_response = client.get(f"/api/v1/jobs/{job_id}", headers=get_headers) + + assert get_response.status_code == 200 + data = get_response.json() + assert data["job_id"] == job_id + assert "job_state" in data + assert "created_at" in data + assert "stages" in data + + def test_get_job_returns_all_stages(self, client, auth_headers): + """Get job should return all associated stages.""" + create_payload = {"client_id": "client-123", "client_name": "test-client"} + create_response = client.post("/api/v1/jobs", json=create_payload, headers=auth_headers) + job_id = create_response.json()["job_id"] + + get_headers = { + "Authorization": auth_headers["Authorization"], + "X-Correlation-Id": auth_headers["X-Correlation-Id"], + } + get_response = client.get(f"/api/v1/jobs/{job_id}", headers=get_headers) + + assert get_response.status_code == 200 + stages = get_response.json()["stages"] + assert len(stages) == 9 + + def test_get_job_returns_correlation_id(self, client, auth_headers, unique_correlation_id): + """Get job should return correlation ID from headers.""" + create_payload = {"client_id": "client-123", "client_name": "test-client"} + create_response = client.post("/api/v1/jobs", json=create_payload, headers=auth_headers) + job_id = create_response.json()["job_id"] + + get_headers = { + "Authorization": auth_headers["Authorization"], + "X-Correlation-Id": unique_correlation_id, + } + get_response = client.get(f"/api/v1/jobs/{job_id}", headers=get_headers) + + assert get_response.status_code == 200 + assert get_response.json()["correlation_id"] == unique_correlation_id + + +class TestGetJobNotFound: + """Tests for job retrieval when job doesn't exist.""" + + def test_get_nonexistent_job_returns_404(self, client, auth_headers): + """Get nonexistent job should return 404 Not Found.""" + nonexistent_job_id = "019bf590-1234-7890-abcd-ef1234567890" + + get_headers = { + "Authorization": auth_headers["Authorization"], + "X-Correlation-Id": auth_headers["X-Correlation-Id"], + } + response = client.get(f"/api/v1/jobs/{nonexistent_job_id}", headers=get_headers) + + assert response.status_code == 404 + + def test_get_job_invalid_uuid_format_returns_400(self, client, auth_headers): + """Get job with invalid UUID format should return 400 Bad Request.""" + invalid_job_id = "not-a-valid-uuid" + + get_headers = { + "Authorization": auth_headers["Authorization"], + "X-Correlation-Id": auth_headers["X-Correlation-Id"], + } + response = client.get(f"/api/v1/jobs/{invalid_job_id}", headers=get_headers) + + assert response.status_code == 400 + + +class TestGetJobAuthentication: + """Tests for authentication in job retrieval.""" + + def test_get_job_missing_authorization_returns_422(self, client, unique_correlation_id): + """Get job without auth header should return 422 Unprocessable Entity.""" + job_id = "019bf590-1234-7890-abcd-ef1234567890" + headers = {"X-Correlation-Id": unique_correlation_id} + + response = client.get(f"/api/v1/jobs/{job_id}", headers=headers) + + assert response.status_code == 422 + + def test_get_job_invalid_authorization_format_returns_401(self, client, unique_correlation_id): + """Get job with invalid auth format should return 401 Unauthorized.""" + job_id = "019bf590-1234-7890-abcd-ef1234567890" + headers = { + "Authorization": "InvalidFormat test-token", + "X-Correlation-Id": unique_correlation_id, + } + + response = client.get(f"/api/v1/jobs/{job_id}", headers=headers) + + assert response.status_code == 401 + + +class TestGetJobClientIsolation: + """Tests for client isolation in job retrieval.""" + + def test_different_client_cannot_access_job( + self, client, unique_idempotency_key, unique_correlation_id + ): + """Different client should not be able to access another client's job.""" + create_headers = { + "Authorization": "Bearer client-a", + "X-Correlation-Id": unique_correlation_id, + "Idempotency-Key": unique_idempotency_key, + } + create_payload = {"client_id": "client-123", "client_name": "test-client"} + create_response = client.post("/api/v1/jobs", json=create_payload, headers=create_headers) + assert create_response.status_code == 201 + job_id = create_response.json()["job_id"] + + get_headers = { + "Authorization": "Bearer client-b", + "X-Correlation-Id": unique_correlation_id, + } + get_response = client.get(f"/api/v1/jobs/{job_id}", headers=get_headers) + + assert get_response.status_code in [403, 404] diff --git a/build_stream/tests/unit/api/__init__.py b/build_stream/tests/unit/api/__init__.py new file mode 100644 index 0000000000..54b309aafe --- /dev/null +++ b/build_stream/tests/unit/api/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/build_stream/tests/unit/api/jobs/__init__.py b/build_stream/tests/unit/api/jobs/__init__.py new file mode 100644 index 0000000000..54b309aafe --- /dev/null +++ b/build_stream/tests/unit/api/jobs/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/build_stream/tests/unit/api/jobs/test_dependencies.py b/build_stream/tests/unit/api/jobs/test_dependencies.py new file mode 100644 index 0000000000..281688c108 --- /dev/null +++ b/build_stream/tests/unit/api/jobs/test_dependencies.py @@ -0,0 +1,138 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for API dependencies.""" + +import pytest +from fastapi import HTTPException + +from build_stream.api.jobs.dependencies import get_client_id, get_idempotency_key +from build_stream.core.jobs.value_objects import ClientId + + +class TestGetClientId: + """Tests for get_client_id dependency function.""" + + def test_valid_bearer_token_returns_client_id(self): + """Valid bearer token should return ClientId with token value.""" + authorization = "Bearer test-client-123" + + client_id = get_client_id(authorization) + + assert isinstance(client_id, ClientId) + assert client_id.value == "test-client-123" + + def test_bearer_token_with_spaces_trimmed(self): + """Bearer token with spaces should preserve spaces after Bearer prefix.""" + authorization = "Bearer test-client-123 " + + client_id = get_client_id(authorization) + + assert client_id.value == "test-client-123 " + + def test_long_token_truncated_to_128_chars(self): + """Long token should be truncated to 128 characters.""" + long_token = "a" * 200 + authorization = f"Bearer {long_token}" + + client_id = get_client_id(authorization) + + assert len(client_id.value) == 128 + assert client_id.value == long_token[:128] + + def test_missing_bearer_prefix_raises_401(self): + """Missing Bearer prefix should raise 401 HTTPException.""" + authorization = "InvalidFormat test-token" + + with pytest.raises(HTTPException) as exc_info: + get_client_id(authorization) + + assert exc_info.value.status_code == 401 + assert "Invalid authorization header format" in exc_info.value.detail + + def test_empty_token_raises_401(self): + """Empty token should raise 401 HTTPException.""" + authorization = "Bearer " + + with pytest.raises(HTTPException) as exc_info: + get_client_id(authorization) + + assert exc_info.value.status_code == 401 + assert "Missing authentication token" in exc_info.value.detail + + def test_bearer_only_raises_401(self): + """Bearer prefix only should raise 401 HTTPException.""" + authorization = "Bearer" + + with pytest.raises(HTTPException) as exc_info: + get_client_id(authorization) + + assert exc_info.value.status_code == 401 + + +class TestGetIdempotencyKey: + """Tests for get_idempotency_key dependency function.""" + + def test_valid_idempotency_key_returned(self): + """Valid idempotency key should be returned unchanged.""" + key = "test-key-12345" + + result = get_idempotency_key(key) + + assert result == "test-key-12345" + + def test_idempotency_key_with_special_chars(self): + """Idempotency key with special characters should be accepted.""" + key = "test-key-abc-123_xyz" + + result = get_idempotency_key(key) + + assert result == "test-key-abc-123_xyz" + + def test_empty_idempotency_key_raises_422(self): + """Empty idempotency key should raise 422 HTTPException.""" + key = "" + + with pytest.raises(HTTPException) as exc_info: + get_idempotency_key(key) + + assert exc_info.value.status_code == 422 + + def test_whitespace_only_key_raises_422(self): + """Whitespace-only idempotency key should raise 422 HTTPException.""" + key = " " + + with pytest.raises(HTTPException) as exc_info: + get_idempotency_key(key) + + assert exc_info.value.status_code == 422 + + def test_key_exceeding_max_length_raises_422(self): + """Key exceeding max length should raise 422 HTTPException.""" + key = "a" * 256 + + with pytest.raises(HTTPException) as exc_info: + get_idempotency_key(key) + + assert exc_info.value.status_code == 422 + assert "length" in exc_info.value.detail.lower() + + def test_key_at_max_length_accepted(self): + """Key at max length should be accepted.""" + key = "a" * 255 + + result = get_idempotency_key(key) + + assert result == key + assert len(result) == 255 diff --git a/build_stream/tests/unit/api/jobs/test_schemas.py b/build_stream/tests/unit/api/jobs/test_schemas.py new file mode 100644 index 0000000000..71980a837a --- /dev/null +++ b/build_stream/tests/unit/api/jobs/test_schemas.py @@ -0,0 +1,251 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for API schemas.""" + +# pylint: disable=too-few-public-methods + +import pytest +from pydantic import ValidationError + +from build_stream.api.jobs.schemas import ( + CreateJobRequest, + CreateJobResponse, + GetJobResponse, + StageResponse, + ErrorResponse, +) + + +class TestCreateJobRequest: + """Tests for CreateJobRequest schema validation.""" + + def test_valid_request_with_required_fields(self): + """Valid request with required fields should create schema instance.""" + data = {"client_id": "client-123", "client_name": "test-client"} + + request = CreateJobRequest(**data) + + assert request.client_id == "client-123" + assert request.client_name == "test-client" + assert request.metadata is None + + def test_valid_request_with_metadata(self): + """Valid request with metadata should create schema instance.""" + data = { + "client_id": "client-123", + "client_name": "test-client", + "metadata": {"description": "Test", "tags": ["test"]} + } + + request = CreateJobRequest(**data) + + assert request.client_id == "client-123" + assert request.client_name == "test-client" + assert request.metadata == {"description": "Test", "tags": ["test"]} + + def test_missing_client_id_raises_validation_error(self): + """Missing client_id should raise ValidationError.""" + data = {"client_name": "test-client"} + + with pytest.raises(ValidationError) as exc_info: + CreateJobRequest(**data) + + errors = exc_info.value.errors() + assert any(e["loc"] == ("client_id",) for e in errors) + + def test_missing_client_name_is_allowed(self): + """Test method.""" + data = {"client_id": "client-123"} + + request = CreateJobRequest(**data) + + assert request.client_id == "client-123" + assert request.client_name is None + + def test_empty_client_id_raises_validation_error(self): + """Test method.""" + data = {"client_id": ""} + + with pytest.raises(ValidationError) as exc_info: + CreateJobRequest(**data) + + errors = exc_info.value.errors() + assert any(e["loc"] == ("client_id",) for e in errors) + + def test_empty_client_name_raises_validation_error(self): + """Test method.""" + data = {"client_id": "client-123", "client_name": ""} + + with pytest.raises(ValidationError) as exc_info: + CreateJobRequest(**data) + + errors = exc_info.value.errors() + assert any(e["loc"] == ("client_name",) for e in errors) + + def test_client_id_max_length_validation(self): + """Test method.""" + data = {"client_id": "a" * 256} + + with pytest.raises(ValidationError): + CreateJobRequest(**data) + + def test_client_name_max_length_validation(self): + """Test method.""" + data = {"client_id": "client-123", "client_name": "a" * 256} + + with pytest.raises(ValidationError): + CreateJobRequest(**data) + + def test_metadata_can_be_none(self): + """Test method.""" + data = {"client_id": "client-123", "client_name": "test-client", "metadata": None} + + request = CreateJobRequest(**data) + + assert request.metadata is None + + +class TestCreateJobResponse: + """Test class.""" + + def test_valid_response_with_all_fields(self): + """Test method.""" + data = { + "job_id": "019bf590-1234-7890-abcd-ef1234567890", + "correlation_id": "019bf590-5678-7890-abcd-ef1234567890", + "job_state": "CREATED", + "created_at": "2026-01-25T15:00:00+00:00", + "stages": [] + } + + response = CreateJobResponse(**data) + + assert response.job_id == "019bf590-1234-7890-abcd-ef1234567890" + assert response.correlation_id == "019bf590-5678-7890-abcd-ef1234567890" + assert response.job_state == "CREATED" + assert response.created_at == "2026-01-25T15:00:00+00:00" + assert response.stages == [] + + def test_missing_required_field_raises_validation_error(self): + """Test method.""" + data = { + "job_id": "019bf590-1234-7890-abcd-ef1234567890", + "job_state": "CREATED", + } + + with pytest.raises(ValidationError): + CreateJobResponse(**data) + + +class TestStageResponse: + """Test class.""" + + def test_valid_stage_response(self): + """Test method.""" + data = { + "stage_name": "parse-catalog", + "stage_state": "PENDING", + "started_at": None, + "ended_at": None, + "error_code": None, + "error_summary": None, + } + + stage = StageResponse(**data) + + assert stage.stage_name == "parse-catalog" + assert stage.stage_state == "PENDING" + assert stage.started_at is None + assert stage.ended_at is None + + def test_stage_with_timestamps(self): + """Test method.""" + data = { + "stage_name": "parse-catalog", + "stage_state": "RUNNING", + "started_at": "2026-01-25T15:00:00Z", + "ended_at": None, + "error_code": None, + "error_summary": None, + } + + stage = StageResponse(**data) + + assert stage.started_at == "2026-01-25T15:00:00Z" + assert stage.ended_at is None + + def test_stage_with_error(self): + """Test method.""" + data = { + "stage_name": "parse-catalog", + "stage_state": "FAILED", + "started_at": "2026-01-25T15:00:00Z", + "ended_at": "2026-01-25T15:01:00Z", + "error_code": "CATALOG_PARSE_ERROR", + "error_summary": "Invalid JSON format", + } + + stage = StageResponse(**data) + + assert stage.error_code == "CATALOG_PARSE_ERROR" + assert stage.error_summary == "Invalid JSON format" + + +class TestGetJobResponse: + """Test class.""" + + def test_valid_get_job_response(self): + """Test method.""" + data = { + "job_id": "019bf590-1234-7890-abcd-ef1234567890", + "correlation_id": "019bf590-5678-7890-abcd-ef1234567890", + "job_state": "CREATED", + "created_at": "2026-01-25T15:00:00+00:00", + "stages": [] + } + + response = GetJobResponse(**data) + + assert response.job_id == "019bf590-1234-7890-abcd-ef1234567890" + assert response.stages == [] + + +class TestErrorResponse: + """Test class.""" + + def test_valid_error_response(self): + """Test method.""" + data = { + "error": "VALIDATION_ERROR", + "message": "Invalid request", + "correlation_id": "019bf590-1234-7890-abcd-ef1234567890", + "timestamp": "2026-01-25T15:00:00Z", + } + + response = ErrorResponse(**data) + + assert response.error == "VALIDATION_ERROR" + assert response.message == "Invalid request" + assert response.correlation_id == "019bf590-1234-7890-abcd-ef1234567890" + + def test_error_response_missing_required_field(self): + """Test method.""" + data = { + "error": "VALIDATION_ERROR", + "message": "Invalid request", + } + + with pytest.raises(ValidationError): + ErrorResponse(**data) diff --git a/build_stream/tests/unit/core/jobs/entities/test_job.py b/build_stream/tests/unit/core/jobs/entities/test_job.py index 3a1f26a2e5..fa6424510e 100644 --- a/build_stream/tests/unit/core/jobs/entities/test_job.py +++ b/build_stream/tests/unit/core/jobs/entities/test_job.py @@ -32,7 +32,8 @@ def test_create_job(self): job = Job( job_id=JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11"), client_id=ClientId("client-1"), - catalog_digest="abc123", + request_client_id="req-client-123", + client_name="abc123", ) assert job.job_state == JobState.CREATED assert job.version == 1 @@ -43,7 +44,8 @@ def test_start_job(self): job = Job( job_id=JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11"), client_id=ClientId("client-1"), - catalog_digest="abc123", + request_client_id="req-client-123", + client_name="abc123", ) job.start() assert job.job_state == JobState.IN_PROGRESS @@ -54,8 +56,9 @@ def test_start_job_invalid_state(self): job = Job( job_id=JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11"), client_id=ClientId("client-1"), + request_client_id="req-client-123", job_state=JobState.IN_PROGRESS, - catalog_digest="abc123", + client_name="abc123", ) with pytest.raises(InvalidStateTransitionError): job.start() @@ -65,8 +68,9 @@ def test_complete_job(self): job = Job( job_id=JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11"), client_id=ClientId("client-1"), + request_client_id="req-client-123", job_state=JobState.IN_PROGRESS, - catalog_digest="abc123", + client_name="abc123", ) job.complete() assert job.job_state == JobState.COMPLETED @@ -77,7 +81,8 @@ def test_complete_job_invalid_state(self): job = Job( job_id=JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11"), client_id=ClientId("client-1"), - catalog_digest="abc123", + request_client_id="req-client-123", + client_name="abc123", ) with pytest.raises(InvalidStateTransitionError): job.complete() @@ -87,8 +92,9 @@ def test_fail_job(self): job = Job( job_id=JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11"), client_id=ClientId("client-1"), + request_client_id="req-client-123", job_state=JobState.IN_PROGRESS, - catalog_digest="abc123", + client_name="abc123", ) job.fail() assert job.job_state == JobState.FAILED @@ -99,7 +105,8 @@ def test_cancel_job_from_created(self): job = Job( job_id=JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11"), client_id=ClientId("client-1"), - catalog_digest="abc123", + request_client_id="req-client-123", + client_name="abc123", ) job.cancel() assert job.job_state == JobState.CANCELLED @@ -110,8 +117,9 @@ def test_cancel_job_from_in_progress(self): job = Job( job_id=JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11"), client_id=ClientId("client-1"), + request_client_id="req-client-123", job_state=JobState.IN_PROGRESS, - catalog_digest="abc123", + client_name="abc123", ) job.cancel() assert job.job_state == JobState.CANCELLED @@ -121,8 +129,9 @@ def test_terminal_state_prevents_transitions(self): job = Job( job_id=JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11"), client_id=ClientId("client-1"), + request_client_id="req-client-123", job_state=JobState.COMPLETED, - catalog_digest="abc123", + client_name="abc123", ) with pytest.raises(TerminalStateViolationError): job.start() @@ -138,8 +147,9 @@ def test_tombstone_job(self): job = Job( job_id=JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11"), client_id=ClientId("client-1"), + request_client_id="req-client-123", job_state=JobState.COMPLETED, - catalog_digest="abc123", + client_name="abc123", ) job.tombstone() assert job.tombstoned is True @@ -150,8 +160,9 @@ def test_job_state_predicates(self): job = Job( job_id=JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11"), client_id=ClientId("client-1"), + request_client_id="req-client-123", job_state=JobState.COMPLETED, - catalog_digest="abc123", + client_name="abc123", ) assert job.is_completed() is True assert job.is_failed() is False diff --git a/build_stream/tests/unit/core/jobs/test_value_objects.py b/build_stream/tests/unit/core/jobs/test_value_objects.py index 2bc26b8e58..6ef706da40 100644 --- a/build_stream/tests/unit/core/jobs/test_value_objects.py +++ b/build_stream/tests/unit/core/jobs/test_value_objects.py @@ -14,6 +14,8 @@ """Unit tests for Job domain value objects.""" +import uuid + import pytest from build_stream.core.jobs.value_objects import ( @@ -32,29 +34,32 @@ class TestJobId: """Tests for JobId value object.""" - def test_valid_uuid_v7(self): - """Valid UUID v7 should be accepted.""" - job_id = JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11") - assert job_id.value == "018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11" + @staticmethod + def _uuid_str() -> str: + """Generate a UUID string for tests (version-agnostic).""" + return str(uuid.uuid4()) - def test_valid_uuid_v7_uppercase(self): - """UUID v7 with uppercase letters should be accepted.""" - job_id = JobId("018F3C4C-6A2E-7B2A-9C2A-3D8D2C4B9A11") - assert job_id.value == "018F3C4C-6A2E-7B2A-9C2A-3D8D2C4B9A11" + def test_valid_uuid_any_version(self): + """Any valid UUID (e.g., v4) should be accepted.""" + raw = self._uuid_str() + job_id = JobId(raw) + assert job_id.value == raw - def test_invalid_uuid_v4(self): - """UUID v4 should be rejected (version digit is 4, not 7).""" - with pytest.raises(ValueError, match="Invalid UUID v7 format"): - JobId("550e8400-e29b-41d4-a716-446655440000") + def test_uuid_is_normalized_lowercase(self): + """Uppercase UUID strings are normalized to canonical lowercase.""" + raw = self._uuid_str() + upper_raw = raw.upper() + job_id = JobId(upper_raw) + assert job_id.value == raw.lower() def test_invalid_uuid_format(self): """Malformed UUID should be rejected.""" - with pytest.raises(ValueError, match="Invalid UUID v7 format"): + with pytest.raises(ValueError, match="Invalid UUID format"): JobId("not-a-uuid") def test_empty_string(self): """Empty string should be rejected.""" - with pytest.raises(ValueError, match="Invalid UUID v7 format"): + with pytest.raises(ValueError, match="Invalid UUID format"): JobId("") def test_exceeds_maximum_length(self): @@ -64,33 +69,41 @@ def test_exceeds_maximum_length(self): def test_immutability(self): """JobId should be immutable (frozen dataclass).""" - job_id = JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11") + job_id = JobId(self._uuid_str()) with pytest.raises(AttributeError): job_id.value = "018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a12" def test_str_representation(self): """String representation should return value.""" - job_id = JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11") - assert str(job_id) == "018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11" + raw = self._uuid_str() + job_id = JobId(raw) + assert str(job_id) == raw def test_equality(self): """Two JobIds with same value should be equal.""" - job_id1 = JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11") - job_id2 = JobId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11") + raw = self._uuid_str() + job_id1 = JobId(raw) + job_id2 = JobId(raw.upper()) assert job_id1 == job_id2 class TestCorrelationId: """Tests for CorrelationId value object.""" - def test_valid_uuid_v7(self): - """Valid UUID v7 should be accepted.""" - corr_id = CorrelationId("018f3c4b-2d9e-7d1a-8a2b-111111111111") - assert corr_id.value == "018f3c4b-2d9e-7d1a-8a2b-111111111111" + @staticmethod + def _uuid_str() -> str: + """Generate a UUID string for tests (version-agnostic).""" + return str(uuid.uuid4()) + + def test_valid_uuid_any_version(self): + """Any valid UUID (e.g., v4) should be accepted.""" + raw = self._uuid_str() + corr_id = CorrelationId(raw) + assert corr_id.value == raw def test_invalid_uuid_format(self): """Invalid UUID format should be rejected.""" - with pytest.raises(ValueError, match="Invalid UUID v7 format"): + with pytest.raises(ValueError, match="Invalid UUID format"): CorrelationId("invalid-correlation-id") def test_exceeds_maximum_length(self): @@ -100,7 +113,7 @@ def test_exceeds_maximum_length(self): def test_immutability(self): """CorrelationId should be immutable.""" - corr_id = CorrelationId("018f3c4b-2d9e-7d1a-8a2b-111111111111") + corr_id = CorrelationId(self._uuid_str()) with pytest.raises(AttributeError): corr_id.value = "018f3c4b-2d9e-7d1a-8a2b-222222222222" diff --git a/build_stream/tests/unit/infra/test_id_generator.py b/build_stream/tests/unit/infra/test_id_generator.py index 0faf61acbf..74ccb03437 100644 --- a/build_stream/tests/unit/infra/test_id_generator.py +++ b/build_stream/tests/unit/infra/test_id_generator.py @@ -12,40 +12,45 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Unit tests for UUIDv7Generator infrastructure component.""" +"""Unit tests for infrastructure ID generators.""" -import re +import uuid -from build_stream.infra.id_generator import UUIDv7Generator +from build_stream.infra.id_generator import JobUUIDGenerator, UUIDv4Generator -class TestUUIDv7Generator: - """Tests covering UUIDv7Generator behavior.""" +class TestJobUUIDGenerator: + """Tests covering JobUUIDGenerator behavior (UUID v4 under the hood).""" def test_generate_returns_valid_job_id(self) -> None: """Generator should produce a JobId string of expected length.""" - generator = UUIDv7Generator() + generator = JobUUIDGenerator() job_id = generator.generate() assert isinstance(job_id.value, str) assert len(job_id.value) == 36 - - def test_generate_returns_uuid_v7_format(self) -> None: - """Generated JobId must conform to UUID v7 format.""" - generator = UUIDv7Generator() - - job_id = generator.generate() - - assert re.match( - r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - job_id.value.lower(), - ) + # Ensure it parses as a UUID (version-agnostic acceptance) + uuid_obj = uuid.UUID(job_id.value) + assert isinstance(uuid_obj, uuid.UUID) def test_generate_is_unique(self) -> None: """Generator should yield unique IDs over multiple invocations.""" - generator = UUIDv7Generator() + generator = JobUUIDGenerator() generated = {generator.generate().value for _ in range(50)} assert len(generated) == 50 + + +class TestUUIDv4Generator: # pylint: disable=R0903 + """Tests covering generic UUIDv4Generator.""" + + def test_generate_returns_uuid_instance(self) -> None: + """Ensure generator returns a UUID4 instance.""" + generator = UUIDv4Generator() + + value = generator.generate() + + assert isinstance(value, uuid.UUID) + assert value.version == 4 diff --git a/build_stream/tests/unit/orchestrator/jobs/use_cases/test_create_job.py b/build_stream/tests/unit/orchestrator/jobs/use_cases/test_create_job.py index 907176bc5d..816d7e0e48 100644 --- a/build_stream/tests/unit/orchestrator/jobs/use_cases/test_create_job.py +++ b/build_stream/tests/unit/orchestrator/jobs/use_cases/test_create_job.py @@ -1,3 +1,8 @@ +# pylint: disable=too-few-public-methods +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=duplicate-code + # Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -74,14 +79,15 @@ def test_create_job_success( ) command = CreateJobCommand( client_id=ClientId("client-1"), - catalog_digest="abc123def456", + request_client_id="req-client-123", + client_name="abc123def456", correlation_id=CorrelationId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a12"), idempotency_key=IdempotencyKey("idem-key-1"), ) response = use_case.execute(command) assert response.job_id == "018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a11" assert response.client_id == "client-1" - assert response.catalog_digest == "abc123def456" + assert response.client_name == "abc123def456" assert response.job_state == JobState.CREATED.value assert response.version == 1 assert response.tombstoned is False @@ -107,7 +113,8 @@ def test_create_job_persists_job( ) command = CreateJobCommand( client_id=ClientId("client-1"), - catalog_digest="abc123def456", + request_client_id="req-client-123", + client_name="abc123def456", correlation_id=CorrelationId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a12"), idempotency_key=IdempotencyKey("idem-key-1"), ) @@ -139,7 +146,8 @@ def test_create_job_creates_all_stages( ) command = CreateJobCommand( client_id=ClientId("client-1"), - catalog_digest="abc123def456", + request_client_id="req-client-123", + client_name="abc123def456", correlation_id=CorrelationId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a12"), idempotency_key=IdempotencyKey("idem-key-1"), ) @@ -177,7 +185,8 @@ def test_create_job_saves_idempotency_record( ) command = CreateJobCommand( client_id=ClientId("client-1"), - catalog_digest="abc123def456", + request_client_id="req-client-123", + client_name="abc123def456", correlation_id=CorrelationId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a12"), idempotency_key=IdempotencyKey("idem-key-1"), ) @@ -209,7 +218,8 @@ def test_create_job_emits_audit_event( ) command = CreateJobCommand( client_id=ClientId("client-1"), - catalog_digest="abc123def456", + request_client_id="req-client-123", + client_name="abc123def456", correlation_id=CorrelationId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a12"), idempotency_key=IdempotencyKey("idem-key-1"), ) @@ -243,7 +253,8 @@ def test_idempotent_retry_returns_existing_job( ) command = CreateJobCommand( client_id=ClientId("client-1"), - catalog_digest="abc123def456", + request_client_id="req-client-123", + client_name="abc123def456", correlation_id=CorrelationId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a12"), idempotency_key=IdempotencyKey("idem-key-1"), ) @@ -281,13 +292,15 @@ def test_idempotency_conflict_raises_error( ) first_command = CreateJobCommand( client_id=ClientId("client-1"), - catalog_digest="abc123def456", + request_client_id="req-client-123", + client_name="abc123def456", correlation_id=CorrelationId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a12"), idempotency_key=IdempotencyKey("idem-key-1"), ) second_command = CreateJobCommand( client_id=ClientId("client-2"), - catalog_digest="different-digest", + request_client_id="req-client-456", + client_name="different-digest", correlation_id=CorrelationId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a14"), idempotency_key=IdempotencyKey("idem-key-1"), ) @@ -320,13 +333,15 @@ def test_job_already_exists_raises_error( ) first_command = CreateJobCommand( client_id=ClientId("client-1"), - catalog_digest="abc123def456", + request_client_id="req-client-123", + client_name="abc123def456", correlation_id=CorrelationId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a12"), idempotency_key=IdempotencyKey("idem-key-1"), ) second_command = CreateJobCommand( client_id=ClientId("client-1"), - catalog_digest="abc123def456", + request_client_id="req-client-123", + client_name="abc123def456", correlation_id=CorrelationId("018f3c4c-6a2e-7b2a-9c2a-3d8d2c4b9a13"), idempotency_key=IdempotencyKey("idem-key-2"), )