diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0837f38..dc1cd5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,7 @@ jobs: REDIS_PORT: 6379 SECRET_KEY: test-secret-key DEBUG: True - STELLAR_ESCROW_PUBLIC: "GDUMMY" + ESCROW_CONTRACT_ID: "CDUMMY" run: | pytest ${{ env.WORKING_DIR }}/app/tests/ -v --cov=${{ env.WORKING_DIR }}/app --cov-report=xml @@ -142,7 +142,7 @@ jobs: -e REDIS_HOST=test-redis \ -e REDIS_PORT=6379 \ -e SECRET_KEY=test-secret-key \ - -e STELLAR_ESCROW_PUBLIC=GDUMMY \ + -e ESCROW_CONTRACT_ID=CDUMMY \ -e DEBUG=True \ -p 8000:8000 \ stellarts-api:test diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 1940392..57a9601 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -24,7 +24,7 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable with: - toolchain: "1.90.0" + toolchain: "stable" targets: wasm32-unknown-unknown components: clippy, rustfmt @@ -62,7 +62,7 @@ jobs: - name: Run tests working-directory: contracts - run: cargo test --verbose + run: cargo test --verbose --features testutils - name: Build contracts working-directory: contracts diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index 251a320..2f84c03 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -4,6 +4,11 @@ on: pull_request: paths: - 'frontend/**' + push: + branches: + - main + paths: + - 'frontend/**' jobs: build: diff --git a/.gitignore b/.gitignore index a5720a3..4b3e06e 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.kiro cover/ # Translations diff --git a/backend/alembic/env.py b/backend/alembic/env.py index ce20242..4811987 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -18,6 +18,9 @@ from app.models.payment import Payment from app.models.review import Review from app.models.portfolio import PortfolioItem +from app.models.bom import BOMItem +from app.models.inventory import InventoryCheckResult +from app.models.notification import NotificationEvent # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index af04b75..4255caa 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -6,6 +6,7 @@ auth, booking, health, + inventory, payments, stats, user, @@ -22,3 +23,4 @@ api_router.include_router(admin.router, tags=["admin"]) api_router.include_router(payments.router, prefix="/payments", tags=["payments"]) api_router.include_router(stats.router, tags=["stats"]) +api_router.include_router(inventory.router, tags=["inventory"]) diff --git a/backend/app/api/v1/endpoints/booking.py b/backend/app/api/v1/endpoints/booking.py index 70b2c1f..b7bce78 100644 --- a/backend/app/api/v1/endpoints/booking.py +++ b/backend/app/api/v1/endpoints/booking.py @@ -3,7 +3,7 @@ from decimal import Decimal from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException, status from pydantic import BaseModel from sqlalchemy.orm import Session @@ -33,6 +33,7 @@ from app.services.completion_verification import assess_booking_completion from app.services.geolocation import geolocation_service from app.services.soroban import transition_to_in_progress +from app.services.inventory.tasks import run_inventory_check logger = logging.getLogger(__name__) @@ -203,6 +204,7 @@ def get_all_bookings( def update_booking_status( booking_id: UUID, status_payload: BookingStatusUpdate, + background_tasks: BackgroundTasks, db: Session = Depends(get_db), current_user: User = Depends(get_current_active_user), ): @@ -338,6 +340,10 @@ def update_booking_status( db.commit() db.refresh(booking) + # Trigger background inventory check when booking goes IN_PROGRESS + if new_status == BookingStatus.IN_PROGRESS: + background_tasks.add_task(run_inventory_check, booking.id, db) + return { "message": f"Booking {booking_id} status updated", "updated_by": current_user.id, diff --git a/backend/app/api/v1/endpoints/inventory.py b/backend/app/api/v1/endpoints/inventory.py new file mode 100644 index 0000000..66ba7a2 --- /dev/null +++ b/backend/app/api/v1/endpoints/inventory.py @@ -0,0 +1,91 @@ +from datetime import datetime, timedelta, timezone +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.auth import require_client, get_current_active_user +from app.db.session import get_db +from app.models.booking import Booking, BookingStatus +from app.models.client import Client +from app.models.inventory import InventoryCheckResult +from app.models.user import User +from app.schemas.booking import BookingResponse +from app.schemas.inventory import ClientSupplyOverrideRequest, InventoryCheckResultResponse + +router = APIRouter() + +_STALE_THRESHOLD = timedelta(hours=24) + + +@router.get("/inventory/{booking_id}/results", response_model=list[InventoryCheckResultResponse]) +def get_inventory_results( + booking_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Return inventory check results for a booking. Staleness is computed at query time.""" + rows = ( + db.query(InventoryCheckResult) + .filter(InventoryCheckResult.booking_id == booking_id) + .all() + ) + + now = datetime.now(timezone.utc) + results = [] + for row in rows: + checked_at = row.checked_at + # Ensure timezone-aware comparison + if checked_at is not None and checked_at.tzinfo is None: + checked_at = checked_at.replace(tzinfo=timezone.utc) + + computed_status = row.status + if checked_at is not None and checked_at < now - _STALE_THRESHOLD: + computed_status = "stale" + + results.append( + InventoryCheckResultResponse( + id=row.id, + booking_id=row.booking_id, + bom_item_id=row.bom_item_id, + store_id=row.store_id, + store_name=row.store_name, + store_address=row.store_address, + available=row.available, + pre_pay_url=row.pre_pay_url, + status=computed_status, + checked_at=row.checked_at, + ) + ) + + return results + + +@router.post("/bookings/{booking_id}/supply-override", response_model=BookingResponse) +def set_supply_override( + booking_id: UUID, + payload: ClientSupplyOverrideRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_client), +): + """Set client_supply_override on a booking. Caller must be the booking owner.""" + booking = db.query(Booking).filter(Booking.id == booking_id).first() + if not booking: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Booking not found") + + # Verify caller is the booking owner via their client profile + client = db.query(Client).filter(Client.user_id == current_user.id).first() + if not client or booking.client_id != client.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not the booking owner") + + # Only allow override on PENDING or CONFIRMED bookings + if booking.status not in (BookingStatus.PENDING, BookingStatus.CONFIRMED): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Supply override only allowed on pending or confirmed bookings", + ) + + booking.client_supply_override = payload.client_supply_override + db.commit() + db.refresh(booking) + return booking diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2318afa..6ae67db 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -25,6 +25,8 @@ class Settings(BaseSettings): REDIS_PORT: int = 6380 REDIS_DB: int = 0 NEARBY_CACHE_TTL: int = 60 # Seconds to cache nearby-artisan search results + INVENTORY_CACHE_TTL: int = 300 # Seconds to cache store inventory responses + STORE_API_TIMEOUT_S: int = 5 # Per-adapter timeout in seconds # CORS BACKEND_CORS_ORIGINS: list[AnyHttpUrl] = [] @@ -55,6 +57,10 @@ def assemble_cors_origins(cls, v: str | list[str]) -> list[str] | str: STRIPE_SECRET_KEY: str | None = None STRIPE_PUBLISHABLE_KEY: str | None = None + # Firebase Cloud Messaging + FCM_PROJECT_ID: str | None = None + FCM_SERVICE_ACCOUNT_JSON: str | None = None + # Soroban Configuration # Optional vision model configuration for completion verification VISION_API_URL: str | None = None diff --git a/backend/app/db/base.py b/backend/app/db/base.py index a05cbe0..7dd6420 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -13,3 +13,11 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() + +# Import all models here so they are registered with Base.metadata +# (used by Alembic autogenerate and app startup) +def _register_models(): + from app.models import user, artisan, client, booking, payment, review, portfolio # noqa: F401 + from app.models import bom, inventory, notification # noqa: F401 + +_register_models() diff --git a/backend/app/models/bom.py b/backend/app/models/bom.py new file mode 100644 index 0000000..30b1e65 --- /dev/null +++ b/backend/app/models/bom.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Uuid +from sqlalchemy.sql import func + +from app.db.base import Base + + +class BOMItem(Base): + __tablename__ = "bom_items" + + id = Column(Integer, primary_key=True) + booking_id = Column(Uuid, ForeignKey("bookings.id"), nullable=False) + sku = Column(String(100), nullable=False) + name = Column(String(300), nullable=False) + quantity = Column(Integer, nullable=False, default=1) + unit = Column(String(50)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/booking.py b/backend/app/models/booking.py index 0daa3b8..a526d04 100644 --- a/backend/app/models/booking.py +++ b/backend/app/models/booking.py @@ -3,6 +3,7 @@ from sqlalchemy import ( DECIMAL, + Boolean, Column, DateTime, Enum, @@ -44,6 +45,8 @@ class Booking(Base): date = Column(DateTime(timezone=True)) location = Column(String(500)) notes = Column(Text) + client_supply_override = Column(Boolean, default=False, nullable=False) + artisan_device_token = Column(String(512), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() diff --git a/backend/app/models/inventory.py b/backend/app/models/inventory.py new file mode 100644 index 0000000..f5a3fc2 --- /dev/null +++ b/backend/app/models/inventory.py @@ -0,0 +1,19 @@ +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Uuid +from sqlalchemy.sql import func + +from app.db.base import Base + + +class InventoryCheckResult(Base): + __tablename__ = "inventory_check_results" + + id = Column(Integer, primary_key=True) + booking_id = Column(Uuid, ForeignKey("bookings.id"), nullable=False) + bom_item_id = Column(Integer, ForeignKey("bom_items.id"), nullable=False) + store_id = Column(String(100), nullable=False) + store_name = Column(String(300), nullable=False) + store_address = Column(String(500)) + available = Column(Boolean, nullable=False) + pre_pay_url = Column(String(2048)) + status = Column(String(20), default="fresh") + checked_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..1bbaf3b --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,16 @@ +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Uuid +from sqlalchemy.sql import func + +from app.db.base import Base + + +class NotificationEvent(Base): + __tablename__ = "notification_events" + + id = Column(Integer, primary_key=True) + artisan_id = Column(Integer, ForeignKey("artisans.id"), nullable=False) + booking_id = Column(Uuid, ForeignKey("bookings.id"), nullable=False) + store_id = Column(String(100), nullable=False) + item_sku = Column(String(100), nullable=False) + sent_at = Column(DateTime(timezone=True), server_default=func.now()) + fcm_success = Column(Boolean, nullable=False) diff --git a/backend/app/schemas/booking.py b/backend/app/schemas/booking.py index 9e8ff91..9eb1f65 100644 --- a/backend/app/schemas/booking.py +++ b/backend/app/schemas/booking.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, ConfigDict, Field +from app.schemas.inventory import InventoryCheckResultResponse + class BookingCreate(BaseModel): """Schema for creating a new booking""" @@ -122,3 +124,5 @@ class BookingResponse(BaseModel): notes: str | None created_at: datetime updated_at: datetime | None + client_supply_override: bool = False + inventory_results: list[InventoryCheckResultResponse] = [] diff --git a/backend/app/schemas/inventory.py b/backend/app/schemas/inventory.py new file mode 100644 index 0000000..767553c --- /dev/null +++ b/backend/app/schemas/inventory.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class BOMItemCreate(BaseModel): + sku: str + name: str + quantity: int = Field(ge=1) + unit: str | None = None + + +class BOMItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + booking_id: UUID + sku: str + name: str + quantity: int + unit: str | None + created_at: datetime + + +class InventoryCheckResultResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + booking_id: UUID + bom_item_id: int + store_id: str + store_name: str + store_address: str | None + available: bool + pre_pay_url: str | None + status: str # "fresh" | "stale" | "unavailable" + checked_at: datetime + + +class ClientSupplyOverrideRequest(BaseModel): + client_supply_override: bool diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index f835dd6..7c6deab 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from enum import StrEnum +from enum import Enum, StrEnum from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator diff --git a/backend/app/services/inventory/__init__.py b/backend/app/services/inventory/__init__.py new file mode 100644 index 0000000..21286e8 --- /dev/null +++ b/backend/app/services/inventory/__init__.py @@ -0,0 +1,3 @@ +from .base import StoreAdapterProtocol, StoreItemResult + +__all__ = ["StoreAdapterProtocol", "StoreItemResult"] diff --git a/backend/app/services/inventory/base.py b/backend/app/services/inventory/base.py new file mode 100644 index 0000000..3d8f76b --- /dev/null +++ b/backend/app/services/inventory/base.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol + + +@dataclass +class StoreItemResult: + sku: str + available: bool + quantity_on_hand: int | None = None + unit_price: float | None = None + pre_pay_url: str | None = None + error: str | None = None + + +class StoreAdapterProtocol(Protocol): + store_id: str + store_name: str + store_lat: float + store_lon: float + + async def query_item(self, sku: str, quantity: int) -> StoreItemResult: ... diff --git a/backend/app/services/inventory/checker.py b/backend/app/services/inventory/checker.py new file mode 100644 index 0000000..a7cf876 --- /dev/null +++ b/backend/app/services/inventory/checker.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.models.bom import BOMItem +from app.models.inventory import InventoryCheckResult +from app.services.route_service import RouteCorridorResult, route_service + +from .base import StoreAdapterProtocol, StoreItemResult + +logger = logging.getLogger(__name__) + +_CACHE_KEY = "inv:{store_id}:{sku}" + + +def _serialise(result: StoreItemResult) -> str: + return json.dumps( + { + "sku": result.sku, + "available": result.available, + "quantity_on_hand": result.quantity_on_hand, + "unit_price": result.unit_price, + "pre_pay_url": result.pre_pay_url, + "error": result.error, + } + ) + + +def _deserialise(raw: str) -> StoreItemResult: + data = json.loads(raw) + return StoreItemResult( + sku=data["sku"], + available=data["available"], + quantity_on_hand=data.get("quantity_on_hand"), + unit_price=data.get("unit_price"), + pre_pay_url=data.get("pre_pay_url"), + error=data.get("error"), + ) + + +class InventoryCheckerService: + def __init__( + self, + adapters: list[StoreAdapterProtocol], + cache=None, # Redis client or None + ) -> None: + self._adapters = adapters + self._cache = cache + + async def run_check( + self, + booking_id: UUID, + bom_items: list[BOMItem], + corridor: RouteCorridorResult, + db: Session, + ) -> list[InventoryCheckResult]: + # Filter adapters to those within the corridor + in_corridor = [ + a for a in self._adapters + if route_service.point_in_corridor(a.store_lat, a.store_lon, corridor) + ] + + results: list[InventoryCheckResult] = [] + + for adapter in in_corridor: + for item in bom_items: + cache_key = _CACHE_KEY.format(store_id=adapter.store_id, sku=item.sku) + item_result: StoreItemResult | None = None + + # Try cache first + if self._cache is not None: + try: + cached = await self._cache.get(cache_key) + if cached is not None: + item_result = _deserialise(cached) + except Exception as exc: + logger.warning("Redis get failed for %s: %s", cache_key, exc) + + # Cache miss — call live adapter + if item_result is None: + try: + item_result = await asyncio.wait_for( + adapter.query_item(item.sku, item.quantity), + timeout=settings.STORE_API_TIMEOUT_S, + ) + # Cache the successful result + if self._cache is not None: + try: + await self._cache.set( + cache_key, + _serialise(item_result), + ex=settings.INVENTORY_CACHE_TTL, + ) + except Exception as exc: + logger.warning( + "Redis set failed for %s: %s", cache_key, exc + ) + except Exception as exc: + logger.error( + "Adapter %s failed for sku=%s: %s", + adapter.store_id, + item.sku, + exc, + ) + row = InventoryCheckResult( + booking_id=booking_id, + bom_item_id=item.id, + store_id=adapter.store_id, + store_name=adapter.store_name, + store_address=getattr(adapter, "store_address", None), + available=False, + status="unavailable", + ) + db.add(row) + results.append(row) + continue + + row = InventoryCheckResult( + booking_id=booking_id, + bom_item_id=item.id, + store_id=adapter.store_id, + store_name=adapter.store_name, + store_address=getattr(adapter, "store_address", None), + available=item_result.available, + pre_pay_url=item_result.pre_pay_url, + status="fresh", + ) + db.add(row) + results.append(row) + + db.flush() + return results diff --git a/backend/app/services/inventory/mock_adapter.py b/backend/app/services/inventory/mock_adapter.py new file mode 100644 index 0000000..a5b7793 --- /dev/null +++ b/backend/app/services/inventory/mock_adapter.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import asyncio + +from .base import StoreItemResult + + +class MockStoreAdapter: + """In-memory store adapter for use in tests.""" + + def __init__( + self, + store_id: str, + store_name: str, + store_lat: float, + store_lon: float, + items: dict[str, StoreItemResult] | None = None, + should_raise: Exception | None = None, + should_timeout: bool = False, + ) -> None: + self.store_id = store_id + self.store_name = store_name + self.store_lat = store_lat + self.store_lon = store_lon + self._items: dict[str, StoreItemResult] = items or {} + self._should_raise = should_raise + self._should_timeout = should_timeout + + async def query_item(self, sku: str, quantity: int) -> StoreItemResult: + if self._should_raise is not None: + raise self._should_raise + + if self._should_timeout: + # Sleep longer than any realistic timeout to simulate a hung request + await asyncio.sleep(3600) + + if sku in self._items: + return self._items[sku] + + return StoreItemResult(sku=sku, available=False) diff --git a/backend/app/services/inventory/tasks.py b/backend/app/services/inventory/tasks.py new file mode 100644 index 0000000..c45f6c7 --- /dev/null +++ b/backend/app/services/inventory/tasks.py @@ -0,0 +1,100 @@ +"""Background task: run inventory check when a booking transitions to IN_PROGRESS.""" +from __future__ import annotations + +import logging +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.models.bom import BOMItem +from app.models.booking import Booking +from app.services.inventory.checker import InventoryCheckerService +from app.services.notification_service import notification_service +from app.services.route_service import route_service + +logger = logging.getLogger(__name__) + + +async def run_inventory_check(booking_id: UUID, db: Session) -> None: + """Async background task that performs the full inventory check pipeline. + + Never raises — all errors are caught and logged. + """ + try: + # Load booking + booking = db.query(Booking).filter(Booking.id == booking_id).first() + if not booking: + logger.warning("run_inventory_check: booking %s not found", booking_id) + return + + # Req 3.2 / 3.6: skip entirely when client has supplied materials + if booking.client_supply_override: + logger.info( + "run_inventory_check: skipping booking %s — client_supply_override=True", + booking_id, + ) + return + + # Load BOM items + bom_items = db.query(BOMItem).filter(BOMItem.booking_id == booking_id).all() + if not bom_items: + logger.info( + "run_inventory_check: no BOM items for booking %s — skipping", + booking_id, + ) + return + + # Get artisan coordinates (Req 1.5: log and skip if unavailable) + artisan = booking.artisan + artisan_lat = artisan.latitude if artisan else None + artisan_lon = artisan.longitude if artisan else None + + if artisan_lat is None or artisan_lon is None: + logger.warning( + "run_inventory_check: artisan has no coordinates for booking %s — skipping", + booking_id, + ) + return + + artisan_lat = float(artisan_lat) + artisan_lon = float(artisan_lon) + + # Job site coordinates — use 0.0 placeholder if not parseable + job_lat, job_lon = 0.0, 0.0 + if booking.location: + try: + parts = booking.location.split(",") + if len(parts) >= 2: + job_lat = float(parts[0].strip()) + job_lon = float(parts[1].strip()) + except (ValueError, AttributeError): + logger.warning( + "run_inventory_check: could not parse location '%s' for booking %s", + booking.location, + booking_id, + ) + + # Compute route corridor + corridor = route_service.compute_corridor(artisan_lat, artisan_lon, job_lat, job_lon) + + # Run inventory check (no real adapters wired yet — added in later tasks) + checker = InventoryCheckerService(adapters=[], cache=None) + results = await checker.run_check(booking_id, bom_items, corridor, db) + + # Send notifications if artisan has a device token + if booking.artisan_device_token: + artisan_id = artisan.id if artisan else None + await notification_service.send_batch( + booking.artisan_device_token, + results, + booking_id, + db, + artisan_id, + ) + + db.commit() + + except Exception: + logger.exception( + "run_inventory_check: unhandled error for booking %s", booking_id + ) diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..13ff01f --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,150 @@ +"""NotificationService — sends FCM push notifications for inventory alerts.""" +from __future__ import annotations + +import logging +from uuid import UUID + +import httpx +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.models.inventory import InventoryCheckResult +from app.models.notification import NotificationEvent + +logger = logging.getLogger(__name__) + +FCM_SEND_URL = "https://fcm.googleapis.com/v1/projects/{project_id}/messages:send" + + +def _get_bearer_token() -> str | None: + """Obtain a short-lived OAuth2 bearer token from the FCM service account JSON.""" + if not settings.FCM_SERVICE_ACCOUNT_JSON: + return None + try: + import json + + import google.auth.transport.requests + import google.oauth2.service_account + + info = json.loads(settings.FCM_SERVICE_ACCOUNT_JSON) + credentials = google.oauth2.service_account.Credentials.from_service_account_info( + info, + scopes=["https://www.googleapis.com/auth/firebase.messaging"], + ) + request = google.auth.transport.requests.Request() + credentials.refresh(request) + return credentials.token + except Exception as exc: + logger.warning("Failed to obtain FCM bearer token: %s", exc) + return None + + +class NotificationService: + async def send_inventory_alert( + self, + artisan_device_token: str, + item_name: str, + store_name: str, + store_id: str, + pre_pay_url: str, + booking_id: UUID, + db: Session, + artisan_id: int | None = None, + ) -> bool: + """Send a single FCM push notification and persist a NotificationEvent row. + + Returns True if FCM accepted the message, False otherwise. + """ + payload = { + "message": { + "token": artisan_device_token, + "notification": { + "title": f"Item available: {item_name}", + "body": f"{item_name} is in stock at {store_name}", + }, + "data": { + "item_name": item_name, + "store_name": store_name, + "store_id": store_id, + "pre_pay_url": pre_pay_url, + "booking_id": str(booking_id), + }, + } + } + + fcm_success = False + + if settings.FCM_PROJECT_ID is None: + logger.warning("FCM_PROJECT_ID not configured — skipping FCM call") + else: + token = _get_bearer_token() + url = FCM_SEND_URL.format(project_id=settings.FCM_PROJECT_ID) + headers = {"Authorization": f"Bearer {token}"} if token else {} + try: + async with httpx.AsyncClient() as client: + response = await client.post(url, json=payload, headers=headers) + if response.status_code == 404: + logger.warning( + "FCM device token not found (404) for booking=%s store=%s", + booking_id, + store_id, + ) + elif response.status_code >= 400: + logger.error( + "FCM error %s for booking=%s store=%s: %s", + response.status_code, + booking_id, + store_id, + response.text, + ) + else: + fcm_success = True + except Exception as exc: + logger.error("FCM request failed for booking=%s: %s", booking_id, exc) + + # Always persist the notification event + event = NotificationEvent( + artisan_id=artisan_id or 0, + booking_id=booking_id, + store_id=store_id, + item_sku=item_name, # use item_name as sku proxy when no sku provided + fcm_success=fcm_success, + ) + db.add(event) + db.flush() + + return fcm_success + + async def send_batch( + self, + artisan_device_token: str, + results: list[InventoryCheckResult], + booking_id: UUID, + db: Session, + artisan_id: int | None = None, + ) -> int: + """Send up to 5 notifications for available inventory results. + + Returns the number of notifications sent. + """ + available = [r for r in results if r.available] + capped = available[:5] + + count = 0 + for result in capped: + await self.send_inventory_alert( + artisan_device_token=artisan_device_token, + item_name=result.store_name, # store_name as item proxy; callers may override + store_name=result.store_name, + store_id=result.store_id, + pre_pay_url=result.pre_pay_url or "", + booking_id=booking_id, + db=db, + artisan_id=artisan_id, + ) + count += 1 + + return count + + +notification_service = NotificationService() diff --git a/backend/app/services/route_service.py b/backend/app/services/route_service.py new file mode 100644 index 0000000..769de58 --- /dev/null +++ b/backend/app/services/route_service.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import cos, radians +from typing import NamedTuple + + +class BoundingBox(NamedTuple): + min_lat: float + min_lon: float + max_lat: float + max_lon: float + + +@dataclass +class RouteCorridorResult: + origin: tuple[float, float] # (lat, lon) + destination: tuple[float, float] # (lat, lon) + corridor_half_width_m: float + bounding_box: BoundingBox + + +class RouteService: + """Computes a geographic corridor (bounding box) around the straight-line + segment between two GPS coordinates.""" + + # Metres per degree of latitude (approximately constant) + _M_PER_DEG_LAT: float = 111_320.0 + + def compute_corridor( + self, + origin_lat: float, + origin_lon: float, + dest_lat: float, + dest_lon: float, + half_width_m: float = 500.0, + ) -> RouteCorridorResult: + """Return a RouteCorridorResult whose bounding_box is the axis-aligned + bounding box of the two endpoints expanded by *half_width_m* in every + direction. + + Degree conversion uses the Haversine approximation: + 1° latitude ≈ 111 320 m + 1° longitude ≈ 111 320 * cos(mean_lat) m + """ + mean_lat = (origin_lat + dest_lat) / 2.0 + delta_lat_deg = half_width_m / self._M_PER_DEG_LAT + delta_lon_deg = half_width_m / (self._M_PER_DEG_LAT * cos(radians(mean_lat))) + + min_lat = min(origin_lat, dest_lat) - delta_lat_deg + max_lat = max(origin_lat, dest_lat) + delta_lat_deg + min_lon = min(origin_lon, dest_lon) - delta_lon_deg + max_lon = max(origin_lon, dest_lon) + delta_lon_deg + + return RouteCorridorResult( + origin=(origin_lat, origin_lon), + destination=(dest_lat, dest_lon), + corridor_half_width_m=half_width_m, + bounding_box=BoundingBox(min_lat, min_lon, max_lat, max_lon), + ) + + def point_in_corridor( + self, lat: float, lon: float, corridor: RouteCorridorResult + ) -> bool: + """Return True if (lat, lon) falls within the corridor's bounding box.""" + bb = corridor.bounding_box + return bb.min_lat <= lat <= bb.max_lat and bb.min_lon <= lon <= bb.max_lon + + +# Module-level singleton +route_service = RouteService() diff --git a/backend/app/tests/test_background_task.py b/backend/app/tests/test_background_task.py new file mode 100644 index 0000000..438e376 --- /dev/null +++ b/backend/app/tests/test_background_task.py @@ -0,0 +1,136 @@ +"""Unit tests for the run_inventory_check background task.""" +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest + +from app.services.inventory.tasks import run_inventory_check + + +def _make_booking( + *, + client_supply_override: bool = False, + artisan_lat=None, + artisan_lon=None, + device_token: str | None = None, +): + """Build a minimal mock Booking with an attached Artisan.""" + artisan = MagicMock() + artisan.id = 1 + artisan.latitude = artisan_lat + artisan.longitude = artisan_lon + + booking = MagicMock() + booking.id = uuid4() + booking.client_supply_override = client_supply_override + booking.artisan = artisan + booking.artisan_device_token = device_token + booking.location = None + return booking + + +def _make_db(booking=None, bom_items=None): + """Build a minimal mock Session.""" + db = MagicMock() + + query_mock = MagicMock() + filter_mock = MagicMock() + + # First query call → Booking; second → BOMItem + call_count = {"n": 0} + + def query_side_effect(model): + call_count["n"] += 1 + m = MagicMock() + m.filter.return_value.first.return_value = booking + m.filter.return_value.all.return_value = bom_items or [] + return m + + db.query.side_effect = query_side_effect + return db + + +# --------------------------------------------------------------------------- +# Test 1: client_supply_override=True skips the inventory check +# --------------------------------------------------------------------------- + +def test_client_supply_override_skips_check(): + """When client_supply_override is True, InventoryCheckerService.run_check must not be called.""" + booking = _make_booking(client_supply_override=True) + db = _make_db(booking=booking) + + with patch( + "app.services.inventory.tasks.InventoryCheckerService" + ) as MockChecker: + asyncio.run(run_inventory_check(booking.id, db)) + MockChecker.assert_not_called() + + +# --------------------------------------------------------------------------- +# Test 2: Missing artisan coordinates skips the check +# --------------------------------------------------------------------------- + +def test_missing_artisan_coordinates_skips_check(): + """When artisan has no lat/lon, run_check must not be called.""" + booking = _make_booking(artisan_lat=None, artisan_lon=None) + bom_items = [MagicMock()] + db = _make_db(booking=booking, bom_items=bom_items) + + with patch( + "app.services.inventory.tasks.InventoryCheckerService" + ) as MockChecker: + asyncio.run(run_inventory_check(booking.id, db)) + MockChecker.assert_not_called() + + +# --------------------------------------------------------------------------- +# Test 3: Empty BOM skips the check +# --------------------------------------------------------------------------- + +def test_empty_bom_skips_check(): + """When there are no BOM items, run_check must not be called.""" + booking = _make_booking(artisan_lat=51.5, artisan_lon=-0.1) + db = _make_db(booking=booking, bom_items=[]) + + with patch( + "app.services.inventory.tasks.InventoryCheckerService" + ) as MockChecker: + asyncio.run(run_inventory_check(booking.id, db)) + MockChecker.assert_not_called() + + +# --------------------------------------------------------------------------- +# Test 4: Happy path — run_check is called when all data is present +# --------------------------------------------------------------------------- + +def test_happy_path_calls_run_check(): + """When booking has coordinates and BOM items, run_check should be called.""" + booking = _make_booking(artisan_lat=51.5, artisan_lon=-0.1) + bom_items = [MagicMock()] + db = _make_db(booking=booking, bom_items=bom_items) + + mock_checker_instance = MagicMock() + mock_checker_instance.run_check = AsyncMock(return_value=[]) + + with patch( + "app.services.inventory.tasks.InventoryCheckerService", + return_value=mock_checker_instance, + ): + asyncio.run(run_inventory_check(booking.id, db)) + mock_checker_instance.run_check.assert_called_once() + + +# --------------------------------------------------------------------------- +# Test 5: Exceptions inside the task are swallowed (never raised) +# --------------------------------------------------------------------------- + +def test_exceptions_are_swallowed(): + """Unhandled exceptions inside run_inventory_check must not propagate.""" + db = MagicMock() + db.query.side_effect = RuntimeError("DB exploded") + + # Should not raise + asyncio.run(run_inventory_check(uuid4(), db)) diff --git a/backend/app/tests/test_inventory_checker.py b/backend/app/tests/test_inventory_checker.py new file mode 100644 index 0000000..2fc1724 --- /dev/null +++ b/backend/app/tests/test_inventory_checker.py @@ -0,0 +1,235 @@ +"""Unit tests for InventoryCheckerService.""" +from __future__ import annotations + +import asyncio +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.models.bom import BOMItem +from app.services.inventory.base import StoreItemResult +from app.services.inventory.checker import InventoryCheckerService, _deserialise, _serialise +from app.services.inventory.mock_adapter import MockStoreAdapter +from app.services.route_service import RouteService + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_corridor( + origin=(40.0, -74.0), + dest=(40.1, -74.1), + half_width_m=50_000.0, +): + rs = RouteService() + return rs.compute_corridor(*origin, *dest, half_width_m=half_width_m) + + +def make_bom_item(sku="SKU-001", quantity=1, item_id=1): + item = MagicMock(spec=BOMItem) + item.id = item_id + item.sku = sku + item.quantity = quantity + return item + + +def make_db(): + db = MagicMock() + db.add = MagicMock() + db.flush = MagicMock() + return db + + +# --------------------------------------------------------------------------- +# Corridor filtering +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_adapter_outside_corridor_is_skipped(): + """Adapters whose location falls outside the corridor must not be queried.""" + corridor = make_corridor(origin=(40.0, -74.0), dest=(40.1, -74.1), half_width_m=500.0) + + inside = MockStoreAdapter( + store_id="inside", + store_name="Inside Store", + store_lat=40.05, + store_lon=-74.05, + items={"SKU-001": StoreItemResult(sku="SKU-001", available=True)}, + ) + outside = MockStoreAdapter( + store_id="outside", + store_name="Outside Store", + store_lat=99.0, # far away + store_lon=99.0, + items={"SKU-001": StoreItemResult(sku="SKU-001", available=True)}, + ) + + svc = InventoryCheckerService(adapters=[inside, outside], cache=None) + bom = [make_bom_item()] + db = make_db() + + results = await svc.run_check(uuid.uuid4(), bom, corridor, db) + + store_ids = {r.store_id for r in results} + assert "inside" in store_ids + assert "outside" not in store_ids + + +@pytest.mark.asyncio +async def test_no_adapters_in_corridor_returns_empty(): + corridor = make_corridor(origin=(40.0, -74.0), dest=(40.1, -74.1), half_width_m=500.0) + adapter = MockStoreAdapter( + store_id="far", + store_name="Far Store", + store_lat=99.0, + store_lon=99.0, + ) + svc = InventoryCheckerService(adapters=[adapter], cache=None) + results = await svc.run_check(uuid.uuid4(), [make_bom_item()], corridor, make_db()) + assert results == [] + + +# --------------------------------------------------------------------------- +# Cache hit +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_cache_hit_skips_adapter_call(): + """When Redis returns a cached value, adapter.query_item must not be called.""" + corridor = make_corridor(half_width_m=50_000.0) + + adapter = MockStoreAdapter( + store_id="s1", + store_name="Store 1", + store_lat=40.05, + store_lon=-74.05, + ) + # Spy on query_item + adapter.query_item = AsyncMock(return_value=StoreItemResult(sku="SKU-001", available=True)) + + cached_result = StoreItemResult(sku="SKU-001", available=True, pre_pay_url="http://pay") + cache = AsyncMock() + cache.get = AsyncMock(return_value=_serialise(cached_result)) + cache.set = AsyncMock() + + svc = InventoryCheckerService(adapters=[adapter], cache=cache) + bom = [make_bom_item()] + results = await svc.run_check(uuid.uuid4(), bom, corridor, make_db()) + + adapter.query_item.assert_not_called() + assert len(results) == 1 + assert results[0].available is True + + +@pytest.mark.asyncio +async def test_cache_miss_calls_adapter_and_stores_result(): + """On a cache miss the adapter is called and the result is stored in Redis.""" + corridor = make_corridor(half_width_m=50_000.0) + + adapter = MockStoreAdapter( + store_id="s1", + store_name="Store 1", + store_lat=40.05, + store_lon=-74.05, + items={"SKU-001": StoreItemResult(sku="SKU-001", available=True)}, + ) + + cache = AsyncMock() + cache.get = AsyncMock(return_value=None) # cache miss + cache.set = AsyncMock() + + svc = InventoryCheckerService(adapters=[adapter], cache=cache) + bom = [make_bom_item()] + results = await svc.run_check(uuid.uuid4(), bom, corridor, make_db()) + + cache.set.assert_called_once() + assert results[0].available is True + + +# --------------------------------------------------------------------------- +# Error isolation +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_failing_adapter_does_not_prevent_others(): + """An exception from one adapter must not stop other adapters from being queried.""" + corridor = make_corridor(half_width_m=50_000.0) + + failing = MockStoreAdapter( + store_id="fail", + store_name="Failing Store", + store_lat=40.05, + store_lon=-74.05, + should_raise=RuntimeError("boom"), + ) + ok = MockStoreAdapter( + store_id="ok", + store_name="OK Store", + store_lat=40.05, + store_lon=-74.05, + items={"SKU-001": StoreItemResult(sku="SKU-001", available=True)}, + ) + + svc = InventoryCheckerService(adapters=[failing, ok], cache=None) + bom = [make_bom_item()] + results = await svc.run_check(uuid.uuid4(), bom, corridor, make_db()) + + store_ids = {r.store_id for r in results} + assert "fail" in store_ids + assert "ok" in store_ids + + fail_result = next(r for r in results if r.store_id == "fail") + ok_result = next(r for r in results if r.store_id == "ok") + + assert fail_result.status == "unavailable" + assert fail_result.available is False + assert ok_result.available is True + + +@pytest.mark.asyncio +async def test_timeout_marks_result_unavailable(): + """A timed-out adapter call must produce an unavailable result.""" + corridor = make_corridor(half_width_m=50_000.0) + + adapter = MockStoreAdapter( + store_id="slow", + store_name="Slow Store", + store_lat=40.05, + store_lon=-74.05, + should_timeout=True, + ) + + svc = InventoryCheckerService(adapters=[adapter], cache=None) + bom = [make_bom_item()] + + with patch("app.services.inventory.checker.settings") as mock_settings: + mock_settings.STORE_API_TIMEOUT_S = 0.01 # very short timeout + mock_settings.INVENTORY_CACHE_TTL = 300 + results = await svc.run_check(uuid.uuid4(), bom, corridor, make_db()) + + assert len(results) == 1 + assert results[0].status == "unavailable" + assert results[0].available is False + + +# --------------------------------------------------------------------------- +# Serialisation helpers +# --------------------------------------------------------------------------- + +def test_serialise_deserialise_roundtrip(): + original = StoreItemResult( + sku="ABC-123", + available=True, + quantity_on_hand=5, + unit_price=9.99, + pre_pay_url="http://example.com/pay", + error=None, + ) + assert _deserialise(_serialise(original)) == original + + +def test_serialise_deserialise_with_none_fields(): + original = StoreItemResult(sku="X", available=False) + assert _deserialise(_serialise(original)) == original diff --git a/backend/app/tests/test_inventory_endpoints.py b/backend/app/tests/test_inventory_endpoints.py new file mode 100644 index 0000000..bf7a278 --- /dev/null +++ b/backend/app/tests/test_inventory_endpoints.py @@ -0,0 +1,266 @@ +"""Unit tests for inventory API endpoints.""" +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from app.models.booking import Booking, BookingStatus +from app.models.client import Client +from app.models.inventory import InventoryCheckResult +from app.models.user import User + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def get_auth_headers(client, email, password, role): + client.post( + "api/v1/auth/register", + json={ + "email": email, + "password": password, + "role": role, + "full_name": f"Test {role.capitalize()}", + "phone": "1234567890", + }, + ) + login_resp = client.post( + "api/v1/auth/login", json={"email": email, "password": password} + ) + token = login_resp.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def create_artisan_and_booking(client, db_session): + """Register an artisan and a client, create a booking, return (booking_id, client_headers, artisan_headers).""" + artisan_headers = get_auth_headers(client, "art_inv@test.com", "Pass123!", "artisan") + client_headers = get_auth_headers(client, "cli_inv@test.com", "Pass123!", "client") + + # Create artisan profile + resp = client.post( + "api/v1/artisans/profile", + json={ + "business_name": "Test Artisan", + "description": "desc", + "hourly_rate": 50.0, + "specialties": ["plumbing"], + }, + headers=artisan_headers, + ) + artisan_id = resp.json()["id"] + + # Create booking + resp = client.post( + "api/v1/bookings/create", + json={ + "artisan_id": artisan_id, + "service": "Fix sink", + "estimated_hours": 1, + "estimated_cost": 50.0, + "date": "2025-12-25T10:00:00", + }, + headers=client_headers, + ) + assert resp.status_code == 201 + booking_id = resp.json()["id"] + + return booking_id, client_headers, artisan_headers + + +# --------------------------------------------------------------------------- +# GET /inventory/{booking_id}/results +# --------------------------------------------------------------------------- + +def test_get_inventory_results_empty_list(client, db_session): + """Returns empty list when no inventory results exist for a booking.""" + booking_id, client_headers, _ = create_artisan_and_booking(client, db_session) + + resp = client.get(f"api/v1/inventory/{booking_id}/results", headers=client_headers) + assert resp.status_code == 200 + assert resp.json() == [] + + +def _get_booking_uuid(db_session, booking_id_str: str): + """Retrieve the actual Booking UUID object from the DB (handles SQLite UUID quirks).""" + bookings = db_session.query(Booking).all() + for b in bookings: + if str(b.id) == booking_id_str: + return b.id + return uuid.UUID(booking_id_str) + + +def test_get_inventory_results_fresh(client, db_session): + """Returns results with status='fresh' when checked_at is recent.""" + booking_id, client_headers, _ = create_artisan_and_booking(client, db_session) + booking_uuid = _get_booking_uuid(db_session, booking_id) + + result = InventoryCheckResult( + booking_id=booking_uuid, + bom_item_id=1, + store_id="store-1", + store_name="Test Store", + store_address="123 Main St", + available=True, + pre_pay_url="http://pay", + status="fresh", + checked_at=datetime.now(timezone.utc) - timedelta(hours=1), + ) + db_session.add(result) + db_session.commit() + + resp = client.get(f"api/v1/inventory/{booking_id}/results", headers=client_headers) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["status"] == "fresh" + assert data[0]["store_id"] == "store-1" + + +def test_get_inventory_results_marks_stale(client, db_session): + """Results with checked_at older than 24h are returned with status='stale'.""" + booking_id, client_headers, _ = create_artisan_and_booking(client, db_session) + booking_uuid = _get_booking_uuid(db_session, booking_id) + + result = InventoryCheckResult( + booking_id=booking_uuid, + bom_item_id=1, + store_id="store-old", + store_name="Old Store", + store_address=None, + available=True, + pre_pay_url=None, + status="fresh", + checked_at=datetime.now(timezone.utc) - timedelta(hours=25), + ) + db_session.add(result) + db_session.commit() + + resp = client.get(f"api/v1/inventory/{booking_id}/results", headers=client_headers) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["status"] == "stale" + + +def test_get_inventory_results_mixed_staleness(client, db_session): + """Fresh and stale results are correctly differentiated.""" + booking_id, client_headers, _ = create_artisan_and_booking(client, db_session) + booking_uuid = _get_booking_uuid(db_session, booking_id) + + fresh = InventoryCheckResult( + booking_id=booking_uuid, + bom_item_id=1, + store_id="fresh-store", + store_name="Fresh Store", + store_address=None, + available=True, + pre_pay_url=None, + status="fresh", + checked_at=datetime.now(timezone.utc) - timedelta(hours=1), + ) + stale = InventoryCheckResult( + booking_id=booking_uuid, + bom_item_id=2, + store_id="stale-store", + store_name="Stale Store", + store_address=None, + available=False, + pre_pay_url=None, + status="fresh", + checked_at=datetime.now(timezone.utc) - timedelta(hours=30), + ) + db_session.add_all([fresh, stale]) + db_session.commit() + + resp = client.get(f"api/v1/inventory/{booking_id}/results", headers=client_headers) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + + by_store = {r["store_id"]: r["status"] for r in data} + assert by_store["fresh-store"] == "fresh" + assert by_store["stale-store"] == "stale" + + +# --------------------------------------------------------------------------- +# POST /bookings/{booking_id}/supply-override +# --------------------------------------------------------------------------- + +def test_supply_override_returns_403_for_non_owner(client, db_session): + """Returns 403 when the caller is not the booking owner.""" + booking_id, _, _ = create_artisan_and_booking(client, db_session) + + # Register a different client + other_headers = get_auth_headers(client, "other_cli@test.com", "Pass123!", "client") + + resp = client.post( + f"api/v1/bookings/{booking_id}/supply-override", + json={"client_supply_override": True}, + headers=other_headers, + ) + assert resp.status_code == 403 + + +def test_supply_override_returns_403_for_wrong_status(client, db_session): + """Returns 403 when booking status is not PENDING or CONFIRMED.""" + booking_id, client_headers, _ = create_artisan_and_booking(client, db_session) + booking_uuid = _get_booking_uuid(db_session, booking_id) + + # Directly set status to COMPLETED in DB + booking = db_session.query(Booking).filter(Booking.id == booking_uuid).first() + booking.status = BookingStatus.COMPLETED + db_session.commit() + + resp = client.post( + f"api/v1/bookings/{booking_id}/supply-override", + json={"client_supply_override": True}, + headers=client_headers, + ) + assert resp.status_code == 403 + + +def test_supply_override_succeeds_for_owner_pending(client, db_session): + """Owner can set supply override on a PENDING booking.""" + booking_id, client_headers, _ = create_artisan_and_booking(client, db_session) + + resp = client.post( + f"api/v1/bookings/{booking_id}/supply-override", + json={"client_supply_override": True}, + headers=client_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["client_supply_override"] is True + assert data["id"] == booking_id + + +def test_supply_override_succeeds_for_owner_confirmed(client, db_session): + """Owner can set supply override on a CONFIRMED booking.""" + booking_id, client_headers, _ = create_artisan_and_booking(client, db_session) + booking_uuid = _get_booking_uuid(db_session, booking_id) + + booking = db_session.query(Booking).filter(Booking.id == booking_uuid).first() + booking.status = BookingStatus.CONFIRMED + db_session.commit() + + resp = client.post( + f"api/v1/bookings/{booking_id}/supply-override", + json={"client_supply_override": False}, + headers=client_headers, + ) + assert resp.status_code == 200 + assert resp.json()["client_supply_override"] is False + + +def test_supply_override_requires_auth(client): + """Unauthenticated request returns 401 or 403.""" + fake_id = str(uuid.uuid4()) + resp = client.post( + f"api/v1/bookings/{fake_id}/supply-override", + json={"client_supply_override": True}, + ) + assert resp.status_code in (401, 403) diff --git a/backend/app/tests/test_inventory_schemas.py b/backend/app/tests/test_inventory_schemas.py new file mode 100644 index 0000000..bd1fd69 --- /dev/null +++ b/backend/app/tests/test_inventory_schemas.py @@ -0,0 +1,88 @@ +"""Unit tests for inventory Pydantic schemas — serialisation round-trip. + +Validates: Requirements 4.1 +""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from app.schemas.inventory import ( + BOMItemCreate, + BOMItemResponse, + ClientSupplyOverrideRequest, + InventoryCheckResultResponse, +) + + +def _sample_result_data() -> dict: + return { + "id": 1, + "booking_id": uuid4(), + "bom_item_id": 2, + "store_id": "store-abc", + "store_name": "Test Store", + "store_address": "1 High Street", + "available": True, + "pre_pay_url": "https://example.com/pay", + "status": "fresh", + "checked_at": datetime.now(timezone.utc), + } + + +def test_inventory_check_result_round_trip(): + """InventoryCheckResultResponse serialises and deserialises without data loss.""" + data = _sample_result_data() + obj = InventoryCheckResultResponse(**data) + dumped = obj.model_dump() + restored = InventoryCheckResultResponse(**dumped) + + assert restored.id == obj.id + assert restored.booking_id == obj.booking_id + assert restored.bom_item_id == obj.bom_item_id + assert restored.store_id == obj.store_id + assert restored.store_name == obj.store_name + assert restored.store_address == obj.store_address + assert restored.available == obj.available + assert restored.pre_pay_url == obj.pre_pay_url + assert restored.status == obj.status + assert restored.checked_at == obj.checked_at + + +def test_inventory_check_result_nullable_fields(): + """Optional fields accept None without error.""" + data = _sample_result_data() + data["store_address"] = None + data["pre_pay_url"] = None + obj = InventoryCheckResultResponse(**data) + assert obj.store_address is None + assert obj.pre_pay_url is None + + +def test_bom_item_create_validation(): + """BOMItemCreate rejects quantity < 1.""" + import pytest + with pytest.raises(Exception): + BOMItemCreate(sku="X", name="Widget", quantity=0) + + +def test_bom_item_response_round_trip(): + """BOMItemResponse serialises and deserialises without data loss.""" + data = { + "id": 5, + "booking_id": uuid4(), + "sku": "SKU-001", + "name": "Pipe", + "quantity": 3, + "unit": "m", + "created_at": datetime.now(timezone.utc), + } + obj = BOMItemResponse(**data) + restored = BOMItemResponse(**obj.model_dump()) + assert restored == obj + + +def test_client_supply_override_request(): + """ClientSupplyOverrideRequest accepts bool values.""" + assert ClientSupplyOverrideRequest(client_supply_override=True).client_supply_override is True + assert ClientSupplyOverrideRequest(client_supply_override=False).client_supply_override is False diff --git a/backend/app/tests/test_notification_service.py b/backend/app/tests/test_notification_service.py new file mode 100644 index 0000000..3601010 --- /dev/null +++ b/backend/app/tests/test_notification_service.py @@ -0,0 +1,231 @@ +"""Unit tests for NotificationService.""" +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.models.inventory import InventoryCheckResult +from app.services.notification_service import NotificationService + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_db(): + db = MagicMock() + db.add = MagicMock() + db.flush = MagicMock() + return db + + +def make_result(store_id: str, available: bool = True, pre_pay_url: str = "http://pay") -> InventoryCheckResult: + r = MagicMock(spec=InventoryCheckResult) + r.store_id = store_id + r.store_name = f"Store {store_id}" + r.available = available + r.pre_pay_url = pre_pay_url + return r + + +# --------------------------------------------------------------------------- +# send_batch caps at 5 +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_send_batch_caps_at_five(): + """send_batch must send at most 5 notifications regardless of input size.""" + svc = NotificationService() + results = [make_result(str(i)) for i in range(10)] + booking_id = uuid.uuid4() + db = make_db() + + with patch.object(svc, "send_inventory_alert", new_callable=AsyncMock, return_value=True) as mock_alert: + count = await svc.send_batch("token", results, booking_id, db) + + assert count == 5 + assert mock_alert.call_count == 5 + + +@pytest.mark.asyncio +async def test_send_batch_skips_unavailable(): + """send_batch must only notify for available=True results.""" + svc = NotificationService() + results = [ + make_result("a", available=True), + make_result("b", available=False), + make_result("c", available=True), + ] + booking_id = uuid.uuid4() + db = make_db() + + with patch.object(svc, "send_inventory_alert", new_callable=AsyncMock, return_value=True) as mock_alert: + count = await svc.send_batch("token", results, booking_id, db) + + assert count == 2 + assert mock_alert.call_count == 2 + + +# --------------------------------------------------------------------------- +# FCM payload contains required fields +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_fcm_payload_contains_required_fields(): + """FCM payload must include item_name, store_name, store_id, pre_pay_url, booking_id.""" + svc = NotificationService() + booking_id = uuid.uuid4() + db = make_db() + + captured_payload = {} + + async def fake_post(url, json, headers): + captured_payload.update(json) + resp = MagicMock() + resp.status_code = 200 + return resp + + with patch("app.services.notification_service.settings") as mock_settings: + mock_settings.FCM_PROJECT_ID = "test-project" + mock_settings.FCM_SERVICE_ACCOUNT_JSON = None + + with patch("app.services.notification_service._get_bearer_token", return_value="tok"): + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post = AsyncMock(side_effect=fake_post) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + await svc.send_inventory_alert( + artisan_device_token="device-token", + item_name="Hammer", + store_name="Tool Shop", + store_id="store-42", + pre_pay_url="http://pay/123", + booking_id=booking_id, + db=db, + ) + + data = captured_payload["message"]["data"] + assert data["item_name"] == "Hammer" + assert data["store_name"] == "Tool Shop" + assert data["store_id"] == "store-42" + assert data["pre_pay_url"] == "http://pay/123" + assert data["booking_id"] == str(booking_id) + + +# --------------------------------------------------------------------------- +# FCM 404 does not raise +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_fcm_404_does_not_raise(): + """A 404 from FCM must not raise an exception; fcm_success must be False.""" + svc = NotificationService() + booking_id = uuid.uuid4() + db = make_db() + + with patch("app.services.notification_service.settings") as mock_settings: + mock_settings.FCM_PROJECT_ID = "test-project" + mock_settings.FCM_SERVICE_ACCOUNT_JSON = None + + with patch("app.services.notification_service._get_bearer_token", return_value="tok"): + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + resp = MagicMock() + resp.status_code = 404 + mock_client.post = AsyncMock(return_value=resp) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await svc.send_inventory_alert( + artisan_device_token="bad-token", + item_name="Nail", + store_name="Hardware", + store_id="s1", + pre_pay_url="http://pay", + booking_id=booking_id, + db=db, + ) + + assert result is False + + +# --------------------------------------------------------------------------- +# NotificationEvent row is persisted +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_notification_event_persisted_on_success(): + """A NotificationEvent row must be added to the DB on FCM success.""" + svc = NotificationService() + booking_id = uuid.uuid4() + db = make_db() + + with patch("app.services.notification_service.settings") as mock_settings: + mock_settings.FCM_PROJECT_ID = "test-project" + mock_settings.FCM_SERVICE_ACCOUNT_JSON = None + + with patch("app.services.notification_service._get_bearer_token", return_value="tok"): + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + resp = MagicMock() + resp.status_code = 200 + mock_client.post = AsyncMock(return_value=resp) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + await svc.send_inventory_alert( + artisan_device_token="token", + item_name="Bolt", + store_name="Depot", + store_id="s2", + pre_pay_url="http://pay", + booking_id=booking_id, + db=db, + ) + + db.add.assert_called_once() + db.flush.assert_called_once() + + +@pytest.mark.asyncio +async def test_notification_event_persisted_on_fcm_failure(): + """A NotificationEvent row must be added even when FCM returns an error.""" + svc = NotificationService() + booking_id = uuid.uuid4() + db = make_db() + + with patch("app.services.notification_service.settings") as mock_settings: + mock_settings.FCM_PROJECT_ID = "test-project" + mock_settings.FCM_SERVICE_ACCOUNT_JSON = None + + with patch("app.services.notification_service._get_bearer_token", return_value="tok"): + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + resp = MagicMock() + resp.status_code = 500 + resp.text = "internal error" + mock_client.post = AsyncMock(return_value=resp) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await svc.send_inventory_alert( + artisan_device_token="token", + item_name="Screw", + store_name="Depot", + store_id="s3", + pre_pay_url="http://pay", + booking_id=booking_id, + db=db, + ) + + assert result is False + db.add.assert_called_once() + db.flush.assert_called_once() diff --git a/backend/app/tests/test_route_service.py b/backend/app/tests/test_route_service.py new file mode 100644 index 0000000..cd63c6a --- /dev/null +++ b/backend/app/tests/test_route_service.py @@ -0,0 +1,145 @@ +"""Unit tests for RouteService corridor computation (Task 3).""" +from __future__ import annotations + +import math + +import pytest + +from app.services.route_service import BoundingBox, RouteCorridorResult, RouteService + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +svc = RouteService() + +# Approximate metres-per-degree constants (same as implementation) +_M_PER_DEG_LAT = 111_320.0 + + +def _expected_delta_lat(half_width_m: float) -> float: + return half_width_m / _M_PER_DEG_LAT + + +def _expected_delta_lon(half_width_m: float, mean_lat: float) -> float: + return half_width_m / (_M_PER_DEG_LAT * math.cos(math.radians(mean_lat))) + + +# --------------------------------------------------------------------------- +# compute_corridor — bounding box dimensions +# --------------------------------------------------------------------------- + + +class TestComputeCorridor: + def test_returns_route_corridor_result(self): + result = svc.compute_corridor(51.5, -0.1, 51.6, -0.2) + assert isinstance(result, RouteCorridorResult) + assert isinstance(result.bounding_box, BoundingBox) + + def test_origin_and_destination_stored(self): + result = svc.compute_corridor(10.0, 20.0, 11.0, 21.0) + assert result.origin == (10.0, 20.0) + assert result.destination == (11.0, 21.0) + + def test_half_width_stored(self): + result = svc.compute_corridor(0.0, 0.0, 1.0, 1.0, half_width_m=1000.0) + assert result.corridor_half_width_m == 1000.0 + + def test_default_half_width_is_500(self): + result = svc.compute_corridor(0.0, 0.0, 1.0, 1.0) + assert result.corridor_half_width_m == 500.0 + + def test_bounding_box_expands_by_correct_lat_delta(self): + origin_lat, origin_lon = 51.5, -0.1 + dest_lat, dest_lon = 51.6, -0.2 + half_width_m = 500.0 + + result = svc.compute_corridor(origin_lat, origin_lon, dest_lat, dest_lon, half_width_m) + bb = result.bounding_box + + expected_delta_lat = _expected_delta_lat(half_width_m) + assert bb.min_lat == pytest.approx(min(origin_lat, dest_lat) - expected_delta_lat, rel=1e-6) + assert bb.max_lat == pytest.approx(max(origin_lat, dest_lat) + expected_delta_lat, rel=1e-6) + + def test_bounding_box_expands_by_correct_lon_delta(self): + origin_lat, origin_lon = 51.5, -0.1 + dest_lat, dest_lon = 51.6, -0.2 + half_width_m = 500.0 + mean_lat = (origin_lat + dest_lat) / 2.0 + + result = svc.compute_corridor(origin_lat, origin_lon, dest_lat, dest_lon, half_width_m) + bb = result.bounding_box + + expected_delta_lon = _expected_delta_lon(half_width_m, mean_lat) + assert bb.min_lon == pytest.approx(min(origin_lon, dest_lon) - expected_delta_lon, rel=1e-6) + assert bb.max_lon == pytest.approx(max(origin_lon, dest_lon) + expected_delta_lon, rel=1e-6) + + def test_same_origin_and_destination(self): + """A zero-length route should still produce a valid bounding box.""" + result = svc.compute_corridor(48.8566, 2.3522, 48.8566, 2.3522, half_width_m=200.0) + bb = result.bounding_box + assert bb.max_lat > bb.min_lat + assert bb.max_lon > bb.min_lon + + def test_larger_half_width_produces_larger_box(self): + narrow = svc.compute_corridor(0.0, 0.0, 1.0, 1.0, half_width_m=100.0) + wide = svc.compute_corridor(0.0, 0.0, 1.0, 1.0, half_width_m=5000.0) + assert wide.bounding_box.min_lat < narrow.bounding_box.min_lat + assert wide.bounding_box.max_lat > narrow.bounding_box.max_lat + + +# --------------------------------------------------------------------------- +# point_in_corridor +# --------------------------------------------------------------------------- + + +class TestPointInCorridor: + def _corridor(self, half_width_m: float = 500.0) -> RouteCorridorResult: + # London → slightly north-east + return svc.compute_corridor(51.5, -0.1, 51.6, 0.0, half_width_m=half_width_m) + + def test_midpoint_is_inside(self): + corridor = self._corridor() + mid_lat = (51.5 + 51.6) / 2 + mid_lon = (-0.1 + 0.0) / 2 + assert svc.point_in_corridor(mid_lat, mid_lon, corridor) is True + + def test_origin_is_inside(self): + corridor = self._corridor() + assert svc.point_in_corridor(51.5, -0.1, corridor) is True + + def test_destination_is_inside(self): + corridor = self._corridor() + assert svc.point_in_corridor(51.6, 0.0, corridor) is True + + def test_point_far_north_is_outside(self): + corridor = self._corridor() + assert svc.point_in_corridor(55.0, -0.05, corridor) is False + + def test_point_far_east_is_outside(self): + corridor = self._corridor() + assert svc.point_in_corridor(51.55, 10.0, corridor) is False + + def test_point_just_outside_min_lat(self): + corridor = self._corridor(half_width_m=500.0) + just_outside = corridor.bounding_box.min_lat - 0.0001 + assert svc.point_in_corridor(just_outside, -0.05, corridor) is False + + def test_point_just_outside_max_lat(self): + corridor = self._corridor(half_width_m=500.0) + just_outside = corridor.bounding_box.max_lat + 0.0001 + assert svc.point_in_corridor(just_outside, -0.05, corridor) is False + + def test_point_on_boundary_is_inside(self): + corridor = self._corridor(half_width_m=500.0) + bb = corridor.bounding_box + # Exactly on the min_lat boundary + assert svc.point_in_corridor(bb.min_lat, (bb.min_lon + bb.max_lon) / 2, corridor) is True + + def test_narrow_corridor_excludes_wider_point(self): + # Route along the equator; test a point 0.01° north (~1.1 km) + narrow = svc.compute_corridor(0.0, 0.0, 0.0, 1.0, half_width_m=100.0) + wide = svc.compute_corridor(0.0, 0.0, 0.0, 1.0, half_width_m=200_000.0) + # 0.01° ≈ 1 113 m — outside narrow (100 m), inside wide (200 km) + assert svc.point_in_corridor(0.01, 0.5, narrow) is False + assert svc.point_in_corridor(0.01, 0.5, wide) is True diff --git a/frontend/app/dashboard/bookings/page.tsx b/frontend/app/dashboard/bookings/page.tsx index 5d070e4..c020110 100644 --- a/frontend/app/dashboard/bookings/page.tsx +++ b/frontend/app/dashboard/bookings/page.tsx @@ -13,6 +13,7 @@ import { } from "../../../components/ui/card"; import { api, type BookingResponse } from "../../../lib/api"; import { useAuth } from "../../../context/AuthContext"; +import { ClientSupplyOverrideToggle } from "../../../components/booking/ClientSupplyOverrideToggle"; import { Calendar, ArrowLeft } from "lucide-react"; export default function DashboardBookingsPage() { @@ -106,6 +107,14 @@ export default function DashboardBookingsPage() { {b.status}
+ {token && ( ++ No inventory data yet +
+ ); + } + + return ( ++ {result.store_name} +
+ {result.store_address && ( +{result.store_address}
+ )} +