diff --git a/.env.example b/.env.example index 885a0c6..9743d07 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ -# Sea Haven Industries ÔÇö shoc-backend required configuration +# Sea Haven Industries — shoc-backend required configuration # # This file documents the secrets that used to be hardcoded in appsettings*.json # and source files. Copy the values into one of the supported configuration sources; # do NOT commit real values. # # .NET resolves configuration in this order (later wins): -# 1. appsettings.json / appsettings.{Environment}.json (committed ÔÇö placeholders only) +# 1. appsettings.json / appsettings.{Environment}.json (committed — placeholders only) # 2. User Secrets (local dev): dotnet user-secrets set "Key:Sub" "value" # 3. Environment variables (use "__" as the section separator) # diff --git a/.gitattributes b/.gitattributes index 1ff0c42..52cd8b4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,63 +1,2 @@ -############################################################################### -# Set default behavior to automatically normalize line endings. -############################################################################### +# Normalize line endings on checkout and checkin. * text=auto - -############################################################################### -# Set default behavior for command prompt diff. -# -# This is need for earlier builds of msysgit that does not have it on by -# default for csharp files. -# Note: This is only used by command line -############################################################################### -#*.cs diff=csharp - -############################################################################### -# Set the merge driver for project and solution files -# -# Merging from the command prompt will add diff markers to the files if there -# are conflicts (Merging from VS is not affected by the settings below, in VS -# the diff markers are never inserted). Diff markers may cause the following -# file extensions to fail to load in VS. An alternative would be to treat -# these files as binary and thus will always conflict and require user -# intervention with every merge. To do so, just uncomment the entries below -############################################################################### -#*.sln merge=binary -#*.csproj merge=binary -#*.vbproj merge=binary -#*.vcxproj merge=binary -#*.vcproj merge=binary -#*.dbproj merge=binary -#*.fsproj merge=binary -#*.lsproj merge=binary -#*.wixproj merge=binary -#*.modelproj merge=binary -#*.sqlproj merge=binary -#*.wwaproj merge=binary - -############################################################################### -# behavior for image files -# -# image files are treated as binary by default. -############################################################################### -#*.jpg binary -#*.png binary -#*.gif binary - -############################################################################### -# diff behavior for common document formats -# -# Convert binary document formats to text before diffing them. This feature -# is only available from the command line. Turn it on by uncommenting the -# entries below. -############################################################################### -#*.doc diff=astextplain -#*.DOC diff=astextplain -#*.docx diff=astextplain -#*.DOCX diff=astextplain -#*.dot diff=astextplain -#*.DOT diff=astextplain -#*.pdf diff=astextplain -#*.PDF diff=astextplain -#*.rtf diff=astextplain -#*.RTF diff=astextplain diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..15f9114 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ + + +## Summary + + + +## Changes and value + + + +## Ticket + + diff --git a/.github/renovate.json b/.github/renovate.json index 230da6d..70bff15 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,8 +1,22 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "enabledManagers": ["nuget", "github-actions", "terraform"], + "enabledManagers": ["nuget", "github-actions", "terraform", "custom.regex"], + "schedule": ["before 6am every weekday"], "minimumReleaseAge": "3 days", "internalChecksFilter": "strict", + "customManagers": [ + { + "customType": "regex", + "description": [ + "EF_VERSION in the Elastic Beanstalk packaging script installs dotnet-ef at deploy time and must move with the tool manifest and EF Core packages" + ], + "managerFilePatterns": ["/^scripts/package-elastic-beanstalk\\.sh$/"], + "matchStrings": ["EF_VERSION=\"(?\\d+\\.\\d+\\.\\d+)\""], + "datasourceTemplate": "nuget", + "depNameTemplate": "dotnet-ef", + "versioningTemplate": "nuget" + } + ], "packageRules": [ { "description": [ @@ -12,8 +26,10 @@ "dependencyDashboardApproval": true }, { - "description": ["Group non-major nuget updates"], - "matchManagers": ["nuget"], + "description": [ + "Group non-major nuget updates, including the dotnet-ef pin in the packaging script" + ], + "matchManagers": ["nuget", "custom.regex"], "matchUpdateTypes": ["minor", "patch"], "groupName": "nuget minor and patch" }, @@ -35,6 +51,18 @@ "matchUpdateTypes": ["major"], "groupName": "aspnetcore and ef core" }, + { + "description": [ + "net8.0 target: hold ASP.NET Core, EF Core, and dotnet-ef at 8.x until the TargetFramework moves; the group above takes over then" + ], + "matchPackageNames": [ + "Microsoft.AspNetCore{/,}**", + "Microsoft.EntityFrameworkCore{/,}**", + "dotnet-ef" + ], + "matchUpdateTypes": ["major"], + "enabled": false + }, { "description": ["Keep AWS SDK majors together"], "matchPackageNames": ["/^AWSSDK\\./"], diff --git a/.github/workflows/architecture-quality.yml b/.github/workflows/architecture-quality.yml deleted file mode 100644 index 14018b5..0000000 --- a/.github/workflows/architecture-quality.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Architecture and changed-file quality - -on: - pull_request: - -permissions: - contents: read - -jobs: - architecture: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Set up .NET - uses: actions/setup-dotnet@v6 - with: - dotnet-version: "8.0.x" - - # CI and local run the same complete repository gate. - - name: Repository quality gate - shell: bash - env: - BASE_REF: ${{ github.event.pull_request.base.sha }} - HEAD_REF: ${{ github.event.pull_request.head.sha }} - run: bash scripts/governance-check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24a071d..f8d95dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,19 +2,24 @@ name: Backend CI on: pull_request: - branches: [main, dev, staging] + # The merge queue builds main plus the queued pull requests on a temporary + # branch and only counts checks that ran on the merge_group event. + merge_group: + push: + branches: [main] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build-and-test: name: Build and test runs-on: ubuntu-latest timeout-minutes: 20 - concurrency: - group: backend-ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true steps: - name: Checkout uses: actions/checkout@v7 @@ -24,10 +29,13 @@ jobs: with: dotnet-version: "8.0.x" - - name: Set up Terraform - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + - name: Cache NuGet packages + uses: actions/cache@v4 with: - terraform_version: "1.9.8" + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }} + restore-keys: | + nuget-${{ runner.os }}- - name: Restore run: dotnet restore SeaHavenIndustries.sln @@ -38,28 +46,71 @@ jobs: - name: Test run: dotnet test SeaHavenIndustries.sln --no-build --configuration Release - - name: Terraform fmt and validate + architecture: + name: architecture + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: "8.0.x" + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Set up Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.9.8" + terraform_wrapper: false + + - name: Repository quality gate + shell: bash + env: + GOVERNANCE_SKIP_BUILD_TEST: "1" + # A merge group carries its own base and head; github.event.before is + # empty on that event. + BASE_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} + HEAD_REF: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }} + run: bash scripts/governance-check.sh + + review: + name: review + if: github.event_name != 'push' + uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10 + + ci-complete: + name: ci-complete + if: always() + needs: [build-and-test, architecture, review] + runs-on: ubuntu-latest + steps: + - name: All required jobs passed + shell: bash + env: + BUILD: ${{ needs.build-and-test.result }} + ARCH: ${{ needs.architecture.result }} + REVIEW: ${{ needs.review.result }} run: | set -euo pipefail - - directories=() - case "${{ github.base_ref }}" in - dev) - directories+=(terraform/live/dev) - ;; - staging) - directories+=(terraform/live/staging) - ;; - esac - - for dir in "${directories[@]}"; do - terraform -chdir="$dir" fmt -check -recursive - terraform -chdir="$dir" init -backend=false - terraform -chdir="$dir" validate - done - - - name: Terraform import plan guard tests - run: python scripts/test-terraform-import-plan-check.py - - - name: Terraform release plan guard tests - run: python scripts/test-terraform-release-plan-check.py + if [[ "${BUILD}" != "success" || "${ARCH}" != "success" ]]; then + echo "Build and test or architecture did not succeed: build=${BUILD} architecture=${ARCH}" + exit 1 + fi + # review is skipped on push to main; it must succeed on pull_request + # and merge_group. + if [[ "${REVIEW}" != "success" && "${REVIEW}" != "skipped" ]]; then + echo "review did not succeed: ${REVIEW}" + exit 1 + fi diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index 8639143..0000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: Dependency Review -on: - pull_request: -permissions: - contents: read -jobs: - review: - uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10 diff --git a/.github/workflows/deploy-tag.yaml b/.github/workflows/deploy-tag.yaml new file mode 100644 index 0000000..00f9389 --- /dev/null +++ b/.github/workflows/deploy-tag.yaml @@ -0,0 +1,45 @@ +name: Deploy API from tag + +# Human CLI escape hatch. GITHUB_TOKEN tag pushes from release.yaml do not +# start this workflow. No path filters. + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + checks: read + id-token: write + +jobs: + target: + name: Resolve tag + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + environment: ${{ steps.resolve.outputs.environment }} + ref: ${{ steps.resolve.outputs.ref }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - id: resolve + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + environment="$(python3 -c 'import os, sys; sys.path.insert(0, "scripts"); from next_release_tag import parse_environment_from_tag; print(parse_environment_from_tag(os.environ["TAG"]))')" + { + echo "environment=${environment}" + echo "ref=${TAG}" + } >> "${GITHUB_OUTPUT}" + + deploy: + needs: target + uses: ./.github/workflows/deploy.yaml + with: + environment: ${{ needs.target.outputs.environment }} + ref: ${{ needs.target.outputs.ref }} + secrets: inherit diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..a8ecefc --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,405 @@ +name: Deploy API + +# GitHub owns Elastic Beanstalk application versions. Terraform ignores +# version_label. Do not put path filters on tag events; those live in +# deploy-tag.yaml. + +on: + workflow_call: + inputs: + environment: + required: true + type: string + ref: + required: true + type: string + workflow_dispatch: + inputs: + environment: + description: Target Environment + required: true + type: choice + options: [dev, staging, prod] + ref: + description: Git ref to build (tag, branch, or SHA). Empty means this run's SHA. + required: false + type: string + default: "" + push: + branches: [main] + paths-ignore: + - "terraform/**" + - "**/*.md" + - ".github/workflows/ci.yml" + - ".github/workflows/release.yaml" + - ".github/workflows/deploy-tag.yaml" + +permissions: + contents: read + +jobs: + target: + name: Resolve target + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + environment: ${{ steps.resolve.outputs.environment }} + ref: ${{ steps.resolve.outputs.ref }} + steps: + - id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + CALL_ENVIRONMENT: ${{ inputs.environment }} + CALL_REF: ${{ inputs.ref }} + INPUT_ENVIRONMENT: ${{ github.event.inputs.environment }} + INPUT_REF: ${{ github.event.inputs.ref }} + GITHUB_SHA_IN: ${{ github.sha }} + run: | + set -euo pipefail + # A called reusable workflow keeps the caller's github.event_name + # (push or workflow_dispatch), not workflow_call. Prefer the call + # inputs whenever they are set. + if [ -n "${CALL_ENVIRONMENT}" ]; then + environment="${CALL_ENVIRONMENT}" + ref="${CALL_REF:-${GITHUB_SHA_IN}}" + else + case "${EVENT_NAME}" in + workflow_dispatch) + environment="${INPUT_ENVIRONMENT}" + ref="${INPUT_REF:-${GITHUB_SHA_IN}}" + ;; + push) + environment=dev + ref="${GITHUB_SHA_IN}" + ;; + *) + echo "unsupported event ${EVENT_NAME}" >&2 + exit 1 + ;; + esac + fi + case "${environment}" in + dev|staging|prod) ;; + *) + echo "unknown environment ${environment}" >&2 + exit 1 + ;; + esac + { + echo "environment=${environment}" + echo "ref=${ref}" + } >> "${GITHUB_OUTPUT}" + echo "Deploying ${ref} to ${environment}" + + deploy: + name: Deploy ${{ needs.target.outputs.environment }} + needs: target + runs-on: ubuntu-latest + timeout-minutes: 180 + environment: ${{ needs.target.outputs.environment }} + concurrency: + group: deploy-api-${{ needs.target.outputs.environment }} + cancel-in-progress: false + permissions: + contents: read + id-token: write + checks: read + env: + AWS_REGION: us-east-1 + DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }} + TARGET_ENVIRONMENT: ${{ needs.target.outputs.environment }} + TARGET_REF: ${{ needs.target.outputs.ref }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.target.outputs.ref }} + persist-credentials: false + fetch-depth: 0 + + - name: Resolve commit + id: commit + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + echo "sha=${sha}" >> "${GITHUB_OUTPUT}" + echo "Building ${sha}" + + - name: Require tag on main + if: needs.target.outputs.environment != 'dev' + env: + REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + TAG_OR_REF: ${{ needs.target.outputs.ref }} + run: | + set -euo pipefail + status="$(gh api "repos/${REPO}/compare/main...${TAG_OR_REF}" --jq .status)" + if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then + echo "ref ${TAG_OR_REF} is not on main (compare status: ${status})" >&2 + exit 1 + fi + + - name: Require CI on the SHA + if: needs.target.outputs.environment != 'dev' + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + python3 scripts/require_commit_checks.py \ + --repo "${{ github.repository }}" \ + --sha "${{ steps.commit.outputs.sha }}" \ + --timeout-seconds 60 + + - name: Skip prod AWS until live/prod exists + id: prod-gate + if: needs.target.outputs.environment == 'prod' + run: | + set -euo pipefail + if [ "${{ vars.PROD_APP_CD_ENABLED }}" = "true" ]; then + echo "skip_aws=false" >> "${GITHUB_OUTPUT}" + else + echo "PROD_APP_CD_ENABLED is not true; reviewers already approved; skipping AWS." + echo "skip_aws=true" >> "${GITHUB_OUTPUT}" + fi + + - name: Set up .NET + if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true' + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: "8.0.x" + + - name: Build Elastic Beanstalk source bundle + if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true' + run: bash scripts/package-elastic-beanstalk.sh + + - name: Validate exact release bundle + if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true' + run: bash scripts/validate-elastic-beanstalk-bundle.sh + + - name: Configure AWS credentials using OIDC + if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true' + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: ${{ env.DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + audience: sts.amazonaws.com + + - name: Get deploy parameters + id: deploy + if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true' + run: | + set -euo pipefail + prefix="/shoc-backend/${TARGET_ENVIRONMENT}/deploy" + APPLICATION=$(aws ssm get-parameter --name "${prefix}/application-name" --query Parameter.Value --output text) + ENVIRONMENT_NAME=$(aws ssm get-parameter --name "${prefix}/environment-name" --query Parameter.Value --output text) + ARTIFACTS_BUCKET=$(aws ssm get-parameter --name "${prefix}/artifacts-bucket" --query Parameter.Value --output text) + SMOKE_URL=$(aws ssm get-parameter --name "${prefix}/smoke-url" --query Parameter.Value --output text) + { + echo "application=${APPLICATION}" + echo "environment_name=${ENVIRONMENT_NAME}" + echo "artifacts_bucket=${ARTIFACTS_BUCKET}" + echo "smoke_url=${SMOKE_URL}" + } >> "${GITHUB_OUTPUT}" + + - name: Capture current environment version + if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true' + env: + ENVIRONMENT_NAME: ${{ steps.deploy.outputs.environment_name }} + run: | + set -euo pipefail + prev="$(aws elasticbeanstalk describe-environments \ + --environment-names "${ENVIRONMENT_NAME}" \ + --region us-east-1 \ + --query 'Environments[0].VersionLabel' \ + --output text)" + echo "previous_version_label=${prev}" >> "${GITHUB_ENV}" + echo "Previous version label: ${prev}" + + - name: Upload bundle and update environment + if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true' + env: + APPLICATION: ${{ steps.deploy.outputs.application }} + ENVIRONMENT_NAME: ${{ steps.deploy.outputs.environment_name }} + ARTIFACTS_BUCKET: ${{ steps.deploy.outputs.artifacts_bucket }} + GIT_SHA: ${{ steps.commit.outputs.sha }} + run: | + set -euo pipefail + version_label="${GIT_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + s3_key="shoc-backend/releases/${TARGET_ENVIRONMENT}/${GIT_SHA}/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/site.zip" + aws s3 cp .artifacts/elastic-beanstalk/site.zip \ + "s3://${ARTIFACTS_BUCKET}/${s3_key}" \ + --region us-east-1 + aws elasticbeanstalk create-application-version \ + --application-name "${APPLICATION}" \ + --version-label "${version_label}" \ + --description "GitHub Actions ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + --source-bundle "S3Bucket=${ARTIFACTS_BUCKET},S3Key=${s3_key}" \ + --process \ + --region us-east-1 + status="UNPROCESSED" + for _ in $(seq 1 36); do + status="$(aws elasticbeanstalk describe-application-versions \ + --application-name "${APPLICATION}" \ + --version-labels "${version_label}" \ + --region us-east-1 \ + --query 'ApplicationVersions[0].Status' \ + --output text)" + echo "application version status: $status" + if [ "$status" = "PROCESSED" ]; then + break + fi + if [ "$status" = "FAILED" ]; then + echo "Elastic Beanstalk failed to process ${version_label}." >&2 + exit 1 + fi + sleep 5 + done + if [ "$status" != "PROCESSED" ]; then + echo "Application version did not become PROCESSED." >&2 + exit 1 + fi + aws elasticbeanstalk update-environment \ + --environment-name "${ENVIRONMENT_NAME}" \ + --version-label "${version_label}" \ + --region us-east-1 + echo "version_label=${version_label}" >> "${GITHUB_ENV}" + echo "environment_updated=true" >> "${GITHUB_ENV}" + + - name: Verify exact application version is active + if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true' + env: + ENVIRONMENT_NAME: ${{ steps.deploy.outputs.environment_name }} + run: | + set -euo pipefail + expected="${version_label}" + status="Unknown" + current="Unknown" + health="Unknown" + for _ in $(seq 1 80); do + read -r status current health < <( + aws elasticbeanstalk describe-environments \ + --environment-names "${ENVIRONMENT_NAME}" \ + --region us-east-1 \ + --query 'Environments[0].[Status,VersionLabel,Health]' \ + --output text + ) + echo "environment status: $status; version: $current; health: $health" + if [ "$status" = "Ready" ]; then + if [ "$current" != "$expected" ]; then + echo "Environment became Ready on version $current, not the expected $expected." >&2 + exit 1 + fi + if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then + echo "Expected application version is Ready and healthy." + exit 0 + fi + echo "Expected version is active; waiting for health to leave $health." + fi + sleep 15 + done + echo "Expected application version did not become Ready and healthy within the deployment window (last seen: status=$status version=$current health=$health)." >&2 + exit 1 + + - name: Post-deploy smoke + if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true' + run: bash scripts/smoke-elastic-beanstalk.sh "${{ steps.deploy.outputs.smoke_url }}" + + - name: Verify webhook secret source is operational + if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true' + env: + SMOKE_URL: ${{ steps.deploy.outputs.smoke_url }} + run: | + set -euo pipefail + response_file="$(mktemp)" + trap 'rm -f "$response_file"' EXIT + status="$(curl --silent --show-error \ + --output "$response_file" \ + --write-out '%{http_code}' \ + --request POST \ + --header 'Content-Type: application/json' \ + --header "X-SH-Timestamp: $(date +%s)" \ + --header 'X-SH-Key-Id: deployment-smoke-invalid-key' \ + --header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \ + --data '{}' \ + "${SMOKE_URL}/api/webhooks/work-orders")" + if [ "$status" != "401" ]; then + echo "Expected 401 from enabled webhook; received $status." >&2 + sed -n '1,20p' "$response_file" >&2 + exit 1 + fi + + - name: Restore previous application version on failure (schema is not reverted) + if: ${{ failure() && !cancelled() && (needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true') }} + env: + ENVIRONMENT_NAME: ${{ steps.deploy.outputs.environment_name }} + run: | + set -euo pipefail + prev="${previous_version_label:-}" + if [ "${environment_updated:-}" != "true" ]; then + echo "Environment was not updated; nothing to roll back." + exit 0 + fi + if [ -z "$prev" ] || [ "$prev" = "null" ] || [ "$prev" = "None" ] || [ "$prev" = "N/A" ]; then + echo "No previous version recorded; nothing to roll back." >&2 + exit 0 + fi + if [ "$prev" = "${version_label:-}" ]; then + echo "Previous version is the failed release; nothing to roll back." >&2 + exit 0 + fi + + echo "Waiting for any in-flight environment update to settle..." + status="Unknown" + current="Unknown" + health="Unknown" + for _ in $(seq 1 80); do + read -r status current health < <( + aws elasticbeanstalk describe-environments \ + --environment-names "${ENVIRONMENT_NAME}" \ + --region us-east-1 \ + --query 'Environments[0].[Status,VersionLabel,Health]' \ + --output text + ) + echo "environment status: $status; version: $current; health: $health" + if [ "$status" = "Ready" ]; then + break + fi + sleep 15 + done + if [ "$status" != "Ready" ]; then + echo "Environment did not settle before rollback." >&2 + exit 1 + fi + if [ "$current" = "$prev" ]; then + echo "Environment is already on previous version $prev." + exit 0 + fi + + echo "Restoring previous application version $prev." + aws elasticbeanstalk update-environment \ + --environment-name "${ENVIRONMENT_NAME}" \ + --version-label "${prev}" \ + --region us-east-1 + + for _ in $(seq 1 80); do + read -r status current health < <( + aws elasticbeanstalk describe-environments \ + --environment-names "${ENVIRONMENT_NAME}" \ + --region us-east-1 \ + --query 'Environments[0].[Status,VersionLabel,Health]' \ + --output text + ) + echo "environment status: $status; version: $current; health: $health" + if [ "$status" = "Ready" ]; then + if [ "$current" != "$prev" ]; then + echo "Environment became Ready on version $current, not the previous $prev." >&2 + exit 1 + fi + if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then + echo "Previous application version is Ready and healthy." + exit 0 + fi + echo "Previous version is active; waiting for health to leave $health." + fi + sleep 15 + done + echo "Environment did not return to Ready and healthy within the rollback window (last seen: status=$status version=$current health=$health)." >&2 + exit 1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 1cdcf0b..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,864 +0,0 @@ -name: Validate and deploy - -on: - pull_request: - branches: [dev, staging, main] - push: - branches: [dev] - workflow_dispatch: - -permissions: - contents: read - -jobs: - validate: - name: Validate deployable source bundle - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Set up .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: "8.0.x" - - - name: Repository quality gate - env: - BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/dev' }} - HEAD_REF: ${{ github.event.pull_request.head.sha || github.sha }} - run: bash scripts/governance-check.sh - - - name: Build Elastic Beanstalk source bundle - run: bash scripts/package-elastic-beanstalk.sh - - - name: Inspect source bundle contract - run: bash scripts/validate-elastic-beanstalk-bundle.sh - - deploy-dev: - name: Deploy shoc-backend-dev through Terraform - if: > - (github.event_name == 'push' && github.ref == 'refs/heads/dev' && - vars.TERRAFORM_APP_CD_ENABLED == 'true') || - (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev') - needs: validate - runs-on: ubuntu-latest - timeout-minutes: 180 - permissions: - contents: read - id-token: write - environment: - name: dev - concurrency: - group: deploy-dev - cancel-in-progress: false - env: - TF_CLOUD_ORGANIZATION: seahaven - TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} - EB_APPLICATION_NAME: shoc-backend - EB_ENVIRONMENT_NAME: shoc-backend-dev - SMOKE_URL: https://api.dev.seahaven.com - EB_BUCKET: elasticbeanstalk-us-east-1-396287094661 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: "8.0.x" - - - name: Build Elastic Beanstalk source bundle - run: bash scripts/package-elastic-beanstalk.sh - - - name: Validate exact release bundle - run: bash scripts/validate-elastic-beanstalk-bundle.sh - - - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 - with: - role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} - aws-region: us-east-1 - audience: sts.amazonaws.com - - - name: Capture current environment version - run: | - set -euo pipefail - prev="$(aws elasticbeanstalk describe-environments \ - --environment-names "${EB_ENVIRONMENT_NAME}" \ - --region us-east-1 \ - --query 'Environments[0].VersionLabel' \ - --output text)" - echo "$prev" > .artifacts/elastic-beanstalk/previous-version.txt - echo "Previous version label: $prev" - - - name: Assign immutable release identity - id: release - run: | - set -euo pipefail - version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - s3_key="shoc-backend/releases/dev/${GITHUB_SHA}/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/site.zip" - { - echo "version_label=${version_label}" - echo "s3_key=${s3_key}" - } >> "${GITHUB_OUTPUT}" - - - name: Upload immutable bundle - run: | - set -euo pipefail - aws s3 cp .artifacts/elastic-beanstalk/site.zip \ - "s3://${EB_BUCKET}/${{ steps.release.outputs.s3_key }}" \ - --region us-east-1 - - - name: Create Elastic Beanstalk application version - run: | - set -euo pipefail - aws elasticbeanstalk create-application-version \ - --application-name "${EB_APPLICATION_NAME}" \ - --version-label "${{ steps.release.outputs.version_label }}" \ - --description "GitHub Actions ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} attempt ${GITHUB_RUN_ATTEMPT}" \ - --source-bundle "S3Bucket=${EB_BUCKET},S3Key=${{ steps.release.outputs.s3_key }}" \ - --process \ - --region us-east-1 - - status="UNPROCESSED" - for _ in $(seq 1 36); do - status="$(aws elasticbeanstalk describe-application-versions \ - --application-name "${EB_APPLICATION_NAME}" \ - --version-labels "${{ steps.release.outputs.version_label }}" \ - --region us-east-1 \ - --query 'ApplicationVersions[0].Status' \ - --output text)" - echo "application version status: $status" - if [ "$status" = "PROCESSED" ]; then - exit 0 - fi - if [ "$status" = "FAILED" ]; then - echo "Elastic Beanstalk failed to process ${{ steps.release.outputs.version_label }}." >&2 - exit 1 - fi - sleep 5 - done - echo "Application version did not become PROCESSED." >&2 - exit 1 - - - name: Discard blocking VCS run before GitHub CD - run: | - set -euo pipefail - python3 << 'PY' - import json, os, urllib.error, urllib.request - - token = os.environ["TF_API_TOKEN"] - workspace = "shoc-backend-dev" - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/vnd.api+json", - } - - def get(url): - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req) as resp: - return json.load(resp) - - def post(url, payload): - data = json.dumps(payload).encode() - req = urllib.request.Request( - url, data=data, method="POST", headers=headers - ) - try: - with urllib.request.urlopen(req) as resp: - return resp.status - except urllib.error.HTTPError as exc: - if exc.code in (409, 404): - body = exc.read().decode("utf-8", "replace") - print(f"discard returned HTTP {exc.code}: {body}") - return exc.code - raise - - ws = get( - f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{workspace}" - )["data"] - attrs = ws["attributes"] - if attrs.get("auto-apply") is True: - raise SystemExit("shoc-backend-dev auto-apply is on; refuse to continue") - if not attrs.get("speculative-enabled"): - raise SystemExit("speculative plans are off; refuse to continue") - if (attrs.get("vcs-repo") or {}).get("tags-regex"): - raise SystemExit("tag-based VCS triggering is set; refuse to continue") - expected_patterns = [ - "terraform/live/dev/**", - "terraform/live/modules/**", - ] - if attrs.get("trigger-patterns") != expected_patterns: - raise SystemExit( - "trigger-patterns must be " - f"{expected_patterns}; got {attrs.get('trigger-patterns')}" - ) - if not attrs.get("locked"): - print("workspace is unlocked") - raise SystemExit(0) - - current = ( - ws.get("relationships", {}) - .get("current-run", {}) - .get("data") - ) - if not current: - raise SystemExit("workspace is locked without a current run") - run_id = current["id"] - run = get(f"https://app.terraform.io/api/v2/runs/{run_id}")["data"] - run_attrs = run["attributes"] - status = run_attrs.get("status") - plan_only = run_attrs.get("plan-only") - print(f"current run {run_id} status={status} plan-only={plan_only}") - if plan_only: - print("speculative run does not block GitHub CD") - raise SystemExit(0) - if status in {"applying", "apply_queued"}: - raise SystemExit(f"{run_id} is {status}; wait, do not discard an apply") - discardable = { - "pending", "planned", "cost_estimated", "policy_checked", "policy_override" - } - if status not in discardable: - raise SystemExit(f"{run_id} status {status} is not discardable") - code = post( - f"https://app.terraform.io/api/v2/runs/{run_id}/actions/discard", - {"comment": "Discarded so GitHub CD can create the version-only applyable run"}, - ) - print(f"discarded {run_id} http={code}") - PY - - - name: Create Terraform release run - id: release-run - uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 - env: - TF_VAR_release_version_label: '"${{ steps.release.outputs.version_label }}"' - with: - workspace: shoc-backend-dev - message: "Release ${{ steps.release.outputs.version_label }} from GitHub Actions" - - - name: Read Terraform release plan counts - id: release-plan - uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 - with: - plan: ${{ steps.release-run.outputs.plan_id }} - - - name: Reject non-version-only resource counts - env: - PLAN_ADD: ${{ steps.release-plan.outputs.add }} - PLAN_CHANGE: ${{ steps.release-plan.outputs.change }} - PLAN_DESTROY: ${{ steps.release-plan.outputs.destroy }} - run: | - set -euo pipefail - if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "1" ] || [ "$PLAN_DESTROY" != "0" ]; then - echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/1/0." >&2 - exit 1 - fi - - - name: Guard version-only Terraform plan - run: | - set -euo pipefail - python scripts/check-terraform-release-plan.py \ - --plan-id "${{ steps.release-run.outputs.plan_id }}" \ - --expected-version-label "${{ steps.release.outputs.version_label }}" - - - name: Discard release run when the guard fails - if: failure() && steps.release-run.outcome == 'success' - uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 - with: - run: ${{ steps.release-run.outputs.run_id }} - comment: Rejected by the version-only plan guard from GitHub Actions - - - name: Apply Terraform release run - id: release-apply - continue-on-error: true - uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 - with: - run: ${{ steps.release-run.outputs.run_id }} - comment: Apply version-only release from GitHub Actions ${{ github.sha }} - - - name: Treat already-applied release run as success - env: - APPLY_OUTCOME: ${{ steps.release-apply.outcome }} - RUN_ID: ${{ steps.release-run.outputs.run_id }} - run: | - set -euo pipefail - if [ "$APPLY_OUTCOME" = "success" ]; then - echo "Apply succeeded." - exit 0 - fi - python3 << 'PY' - import json, os, urllib.request - run_id = os.environ["RUN_ID"] - token = os.environ["TF_API_TOKEN"] - req = urllib.request.Request( - f"https://app.terraform.io/api/v2/runs/{run_id}", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/vnd.api+json", - }, - ) - with urllib.request.urlopen(req) as resp: - status = json.load(resp)["data"]["attributes"]["status"] - print(f"HCP run {run_id} status={status}") - if status == "applied": - raise SystemExit(0) - raise SystemExit( - f"Apply failed: GitHub outcome={os.environ['APPLY_OUTCOME']} " - f"HCP status={status}" - ) - PY - - - name: Verify exact application version is active - run: | - set -euo pipefail - expected="${{ steps.release.outputs.version_label }}" - status="Unknown" - current="Unknown" - health="Unknown" - - for _ in $(seq 1 80); do - read -r status current health < <( - aws elasticbeanstalk describe-environments \ - --environment-names "${EB_ENVIRONMENT_NAME}" \ - --region us-east-1 \ - --query 'Environments[0].[Status,VersionLabel,Health]' \ - --output text - ) - echo "environment status: $status; version: $current; health: $health" - - if [ "$status" = "Ready" ]; then - if [ "$current" != "$expected" ]; then - echo "Environment became Ready on version $current, not the expected $expected." >&2 - exit 1 - fi - if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then - echo "Expected application version is Ready and healthy." - exit 0 - fi - # The expected version IS active. Elastic Beanstalk reports Ready as - # soon as the rollout finishes, before enhanced health has converged, - # so deciding on the first Ready poll fails a good release on a health - # value that was always going to change. Keep polling; an environment - # that is genuinely unhealthy still fails when the window runs out. - echo "Expected version is active; waiting for health to leave $health." - fi - sleep 15 - done - - echo "Expected application version did not become Ready and healthy within the deployment window (last seen: status=$status version=$current health=$health)." >&2 - exit 1 - - - name: Post-deploy smoke - run: bash scripts/smoke-elastic-beanstalk.sh "${SMOKE_URL}" - - - name: Verify webhook secret source is operational - run: | - set -euo pipefail - response_file="$(mktemp)" - trap 'rm -f "$response_file"' EXIT - status="$(curl --silent --show-error \ - --output "$response_file" \ - --write-out '%{http_code}' \ - --request POST \ - --header 'Content-Type: application/json' \ - --header "X-SH-Timestamp: $(date +%s)" \ - --header 'X-SH-Key-Id: deployment-smoke-invalid-key' \ - --header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \ - --data '{}' \ - "${SMOKE_URL}/api/webhooks/work-orders")" - if [ "$status" != "401" ]; then - echo "Expected enabled webhook with an operational secret source to reject the invalid probe with 401; received $status." >&2 - sed -n '1,20p' "$response_file" >&2 - exit 1 - fi - - - name: Restore previous application version on failure (schema is not reverted) - if: failure() - run: | - set -euo pipefail - prev_file=".artifacts/elastic-beanstalk/previous-version.txt" - if [ ! -f "$prev_file" ]; then - echo "No previous version captured; nothing to roll back." >&2 - exit 0 - fi - prev="$(cat "$prev_file")" - if [ -z "$prev" ] || [ "$prev" = "null" ] || [ "$prev" = "None" ] || [ "$prev" = "N/A" ]; then - echo "No previous version recorded; nothing to roll back." >&2 - exit 0 - fi - - echo "Waiting for any in-flight environment update to settle..." - status="Unknown" - current="Unknown" - health="Unknown" - for _ in $(seq 1 80); do - read -r status current health < <( - aws elasticbeanstalk describe-environments \ - --environment-names "${EB_ENVIRONMENT_NAME}" \ - --region us-east-1 \ - --query 'Environments[0].[Status,VersionLabel,Health]' \ - --output text - ) - echo "environment status: $status; version: $current; health: $health" - if [ "$status" = "Ready" ]; then - break - fi - sleep 15 - done - - if [ "$status" != "Ready" ]; then - echo "Environment did not settle before rollback." >&2 - exit 1 - fi - if [ "$current" = "$prev" ]; then - echo "Environment is already on previous version $prev." - exit 0 - fi - if [[ ! "$prev" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then - echo "Previous version $prev is not a Terraform-managed release label; cannot roll back through HCP." >&2 - exit 1 - fi - echo "rollback_label=$prev" >> "${GITHUB_OUTPUT}" - id: rollback-prepare - - - name: Discard blocking VCS run before GitHub rollback - id: rollback-discard-vcs - if: failure() && steps.rollback-prepare.outputs.rollback_label != '' - run: | - set -euo pipefail - python3 << 'PY' - import json, os, urllib.error, urllib.request - - token = os.environ["TF_API_TOKEN"] - workspace = "shoc-backend-dev" - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/vnd.api+json", - } - - def get(url): - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req) as resp: - return json.load(resp) - - def post(url, payload): - data = json.dumps(payload).encode() - req = urllib.request.Request( - url, data=data, method="POST", headers=headers - ) - try: - with urllib.request.urlopen(req) as resp: - return resp.status - except urllib.error.HTTPError as exc: - if exc.code in (409, 404): - body = exc.read().decode("utf-8", "replace") - print(f"discard returned HTTP {exc.code}: {body}") - return exc.code - raise - - ws = get( - f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{workspace}" - )["data"] - attrs = ws["attributes"] - if attrs.get("auto-apply") is True: - raise SystemExit("shoc-backend-dev auto-apply is on; refuse to continue") - if not attrs.get("speculative-enabled"): - raise SystemExit("speculative plans are off; refuse to continue") - if (attrs.get("vcs-repo") or {}).get("tags-regex"): - raise SystemExit("tag-based VCS triggering is set; refuse to continue") - expected_patterns = [ - "terraform/live/dev/**", - "terraform/live/modules/**", - ] - if attrs.get("trigger-patterns") != expected_patterns: - raise SystemExit( - "trigger-patterns must be " - f"{expected_patterns}; got {attrs.get('trigger-patterns')}" - ) - if not attrs.get("locked"): - print("workspace is unlocked") - raise SystemExit(0) - - current = ( - ws.get("relationships", {}) - .get("current-run", {}) - .get("data") - ) - if not current: - raise SystemExit("workspace is locked without a current run") - run_id = current["id"] - run = get(f"https://app.terraform.io/api/v2/runs/{run_id}")["data"] - run_attrs = run["attributes"] - status = run_attrs.get("status") - plan_only = run_attrs.get("plan-only") - print(f"current run {run_id} status={status} plan-only={plan_only}") - if plan_only: - print("speculative run does not block GitHub CD") - raise SystemExit(0) - if status in {"applying", "apply_queued"}: - raise SystemExit(f"{run_id} is {status}; wait, do not discard an apply") - discardable = { - "pending", "planned", "cost_estimated", "policy_checked", "policy_override" - } - if status not in discardable: - raise SystemExit(f"{run_id} status {status} is not discardable") - code = post( - f"https://app.terraform.io/api/v2/runs/{run_id}/actions/discard", - {"comment": "Discarded so GitHub CD can create the version-only applyable run"}, - ) - print(f"discarded {run_id} http={code}") - PY - - - name: Create Terraform rollback run - id: rollback-run - if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success' - uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 - env: - TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"' - with: - workspace: shoc-backend-dev - message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions" - - name: Read Terraform rollback plan counts - id: rollback-plan - if: failure() && steps.rollback-run.outcome == 'success' - uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 - with: - plan: ${{ steps.rollback-run.outputs.plan_id }} - - - name: Reject non-version-only rollback counts - id: rollback-count-guard - if: failure() && steps.rollback-plan.outcome == 'success' - env: - PLAN_ADD: ${{ steps.rollback-plan.outputs.add }} - PLAN_CHANGE: ${{ steps.rollback-plan.outputs.change }} - PLAN_DESTROY: ${{ steps.rollback-plan.outputs.destroy }} - run: | - set -euo pipefail - if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "1" ] || [ "$PLAN_DESTROY" != "0" ]; then - echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/1/0." >&2 - exit 1 - fi - - - name: Guard version-only Terraform rollback plan - id: rollback-json-guard - if: failure() && steps.rollback-count-guard.outcome == 'success' - run: | - set -euo pipefail - python scripts/check-terraform-release-plan.py \ - --plan-id "${{ steps.rollback-run.outputs.plan_id }}" \ - --expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}" - - - name: Discard rollback run when the guard fails - if: failure() && steps.rollback-run.outcome == 'success' && steps.rollback-json-guard.outcome != 'success' - uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 - with: - run: ${{ steps.rollback-run.outputs.run_id }} - comment: Rejected by the version-only rollback plan guard from GitHub Actions - - - name: Apply Terraform rollback run - id: rollback-apply - if: failure() && steps.rollback-json-guard.outcome == 'success' - continue-on-error: true - uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 - with: - run: ${{ steps.rollback-run.outputs.run_id }} - comment: Apply version-only rollback from GitHub Actions ${{ github.sha }} - - - name: Treat already-applied rollback run as success - id: rollback-apply-result - if: failure() && steps.rollback-apply.outcome != 'skipped' - env: - APPLY_OUTCOME: ${{ steps.rollback-apply.outcome }} - RUN_ID: ${{ steps.rollback-run.outputs.run_id }} - run: | - set -euo pipefail - if [ "$APPLY_OUTCOME" = "success" ]; then - echo "Apply succeeded." - exit 0 - fi - python3 << 'PY' - import json, os, urllib.request - run_id = os.environ["RUN_ID"] - token = os.environ["TF_API_TOKEN"] - req = urllib.request.Request( - f"https://app.terraform.io/api/v2/runs/{run_id}", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/vnd.api+json", - }, - ) - with urllib.request.urlopen(req) as resp: - status = json.load(resp)["data"]["attributes"]["status"] - print(f"HCP run {run_id} status={status}") - if status == "applied": - raise SystemExit(0) - raise SystemExit( - f"Apply failed: GitHub outcome={os.environ['APPLY_OUTCOME']} " - f"HCP status={status}" - ) - PY - - - name: Verify previous application version is active - if: failure() && steps.rollback-apply-result.outcome == 'success' - run: | - set -euo pipefail - prev="${{ steps.rollback-prepare.outputs.rollback_label }}" - echo "Database migrations are not reverted; deployable migrations must follow the expand/contract policy." - status="Unknown" - current="Unknown" - health="Unknown" - for _ in $(seq 1 80); do - read -r status current health < <( - aws elasticbeanstalk describe-environments \ - --environment-names "${EB_ENVIRONMENT_NAME}" \ - --region us-east-1 \ - --query 'Environments[0].[Status,VersionLabel,Health]' \ - --output text - ) - echo "environment status: $status; version: $current; health: $health" - if [ "$status" = "Ready" ]; then - if [ "$current" != "$prev" ]; then - echo "Rollback reached Ready on version $current, not the previous $prev." >&2 - exit 1 - fi - if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then - echo "Application version restore complete; previous code is Ready and healthy." - exit 0 - fi - # Same convergence gap as the release check above: the previous version - # is back, health has not settled yet, and reporting a failed rollback - # here hides the fact that the restore itself worked. - echo "Previous version is active; waiting for health to leave $health." - fi - sleep 15 - done - echo "Environment did not return to Ready and healthy within the rollback window (last seen: status=$status version=$current health=$health)." >&2 - exit 1 - - deploy-staging: - name: Deploy shoc-backend-staging to Elastic Beanstalk - if: > - github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/staging' - needs: validate - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - environment: - name: staging - concurrency: - group: deploy-staging - cancel-in-progress: false - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Resolve deploy target - id: target - run: | - set -euo pipefail - application=shoc-backend - environment=shoc-backend-staging - smoke_url=https://api.staging.seahaven.com - { - echo "application=${application}" - echo "environment=${environment}" - echo "smoke_url=${smoke_url}" - } >> "${GITHUB_OUTPUT}" - { - echo "EB_APPLICATION_NAME=${application}" - echo "EB_ENVIRONMENT_NAME=${environment}" - echo "SMOKE_URL=${smoke_url}" - } >> "${GITHUB_ENV}" - - - name: Set up .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: "8.0.x" - - - name: Build Elastic Beanstalk source bundle - run: bash scripts/package-elastic-beanstalk.sh - - - name: Validate exact release bundle - run: bash scripts/validate-elastic-beanstalk-bundle.sh - - - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 - with: - role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} - aws-region: us-east-1 - audience: sts.amazonaws.com - - - name: Capture current environment version - run: | - set -euo pipefail - prev="$(aws elasticbeanstalk describe-environments \ - --environment-names "${EB_ENVIRONMENT_NAME}" \ - --region us-east-1 \ - --query 'Environments[0].VersionLabel' \ - --output text)" - echo "$prev" > .artifacts/elastic-beanstalk/previous-version.txt - echo "Previous version label: $prev" - - - name: Deploy prebuilt bundle to existing environment - uses: aws-actions/aws-elasticbeanstalk-deploy@7883cdd454c162051bf6fc13389536b045149b4c # v1.0.8 - with: - aws-region: us-east-1 - application-name: ${{ steps.target.outputs.application }} - environment-name: ${{ steps.target.outputs.environment }} - version-label: ${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} - deployment-package-path: .artifacts/elastic-beanstalk/site.zip - s3-bucket-name: elasticbeanstalk-us-east-1-396287094661 - create-application-if-not-exists: "false" - create-environment-if-not-exists: "false" - create-s3-bucket-if-not-exists: "false" - use-existing-application-version-if-available: "false" - wait-for-deployment: "true" - wait-for-environment-recovery: "true" - - - name: Verify exact application version is active - run: | - set -euo pipefail - expected="${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}" - status="Unknown" - current="Unknown" - health="Unknown" - - for _ in $(seq 1 80); do - read -r status current health < <( - aws elasticbeanstalk describe-environments \ - --environment-names "${EB_ENVIRONMENT_NAME}" \ - --region us-east-1 \ - --query 'Environments[0].[Status,VersionLabel,Health]' \ - --output text - ) - echo "environment status: $status; version: $current; health: $health" - - if [ "$status" = "Ready" ]; then - if [ "$current" != "$expected" ]; then - echo "Environment became Ready on version $current, not the expected $expected." >&2 - exit 1 - fi - if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then - echo "Expected application version is Ready and healthy." - exit 0 - fi - # The expected version IS active. Elastic Beanstalk reports Ready as - # soon as the rollout finishes, before enhanced health has converged, - # so deciding on the first Ready poll fails a good release on a health - # value that was always going to change. Keep polling; an environment - # that is genuinely unhealthy still fails when the window runs out. - echo "Expected version is active; waiting for health to leave $health." - fi - sleep 15 - done - - echo "Expected application version did not become Ready and healthy within the deployment window (last seen: status=$status version=$current health=$health)." >&2 - exit 1 - - - name: Post-deploy smoke - run: bash scripts/smoke-elastic-beanstalk.sh "${SMOKE_URL}" - - - name: Verify webhook secret source is operational - run: | - set -euo pipefail - response_file="$(mktemp)" - trap 'rm -f "$response_file"' EXIT - status="$(curl --silent --show-error \ - --output "$response_file" \ - --write-out '%{http_code}' \ - --request POST \ - --header 'Content-Type: application/json' \ - --header "X-SH-Timestamp: $(date +%s)" \ - --header 'X-SH-Key-Id: deployment-smoke-invalid-key' \ - --header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \ - --data '{}' \ - "${SMOKE_URL}/api/webhooks/work-orders")" - if [ "$status" != "401" ]; then - echo "Expected enabled webhook with an operational secret source to reject the invalid probe with 401; received $status." >&2 - sed -n '1,20p' "$response_file" >&2 - exit 1 - fi - - - name: Restore previous application version on failure (schema is not reverted) - if: failure() - run: | - set -euo pipefail - prev_file=".artifacts/elastic-beanstalk/previous-version.txt" - if [ ! -f "$prev_file" ]; then - echo "No previous version captured; nothing to roll back." >&2 - exit 0 - fi - prev="$(cat "$prev_file")" - if [ -z "$prev" ] || [ "$prev" = "null" ] || [ "$prev" = "None" ] || [ "$prev" = "N/A" ]; then - echo "No previous version recorded; nothing to roll back." >&2 - exit 0 - fi - - echo "Waiting for any in-flight environment update to settle..." - status="Unknown" - current="Unknown" - health="Unknown" - for _ in $(seq 1 80); do - read -r status current health < <( - aws elasticbeanstalk describe-environments \ - --environment-names "${EB_ENVIRONMENT_NAME}" \ - --region us-east-1 \ - --query 'Environments[0].[Status,VersionLabel,Health]' \ - --output text - ) - echo "environment status: $status; version: $current; health: $health" - if [ "$status" = "Ready" ]; then - break - fi - sleep 15 - done - - if [ "$status" != "Ready" ]; then - echo "Environment did not settle before rollback." >&2 - exit 1 - fi - if [ "$current" = "$prev" ]; then - echo "Environment is already on previous version $prev." - exit 0 - fi - - echo "Restoring ${EB_ENVIRONMENT_NAME} application code to version label: $prev" - echo "Database migrations are not reverted; deployable migrations must follow the expand/contract policy." - aws elasticbeanstalk update-environment \ - --environment-name "${EB_ENVIRONMENT_NAME}" \ - --version-label "$prev" \ - --region us-east-1 - - echo "Waiting for previous version to become healthy..." - for _ in $(seq 1 80); do - read -r status current health < <( - aws elasticbeanstalk describe-environments \ - --environment-names "${EB_ENVIRONMENT_NAME}" \ - --region us-east-1 \ - --query 'Environments[0].[Status,VersionLabel,Health]' \ - --output text - ) - echo "environment status: $status; version: $current; health: $health" - if [ "$status" = "Ready" ]; then - if [ "$current" != "$prev" ]; then - echo "Rollback reached Ready on version $current, not the previous $prev." >&2 - exit 1 - fi - if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then - echo "Application version restore complete; previous code is Ready and healthy." - exit 0 - fi - # Same convergence gap as the release check above: the previous version - # is back, health has not settled yet, and reporting a failed rollback - # here hides the fact that the restore itself worked. - echo "Previous version is active; waiting for health to leave $health." - fi - sleep 15 - done - - echo "Environment did not return to Ready and healthy within the rollback window (last seen: status=$status version=$current health=$health)." >&2 - exit 1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..e5830a5 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,91 @@ +name: Release + +# Cut a SemVer tag from main HEAD, then call deploy. GITHUB_TOKEN is enough; +# it cannot start other workflows via the tag push, so this file calls deploy. + +on: + workflow_dispatch: + inputs: + environment: + description: Target environment + required: true + type: choice + options: [staging, prod] + bump: + description: SemVer bump from the last prod core tag + required: true + type: choice + options: [patch, minor, major] + message: + description: Annotated tag message and GitHub Release body + required: true + type: string + +permissions: + contents: write + checks: read + id-token: write + +jobs: + cut: + name: Cut tag + runs-on: ubuntu-latest + timeout-minutes: 40 + outputs: + tag: ${{ steps.tag.outputs.tag }} + sha: ${{ steps.tag.outputs.sha }} + environment: ${{ github.event.inputs.environment }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: true + + - name: Require main + run: | + set -euo pipefail + if [ "${GITHUB_REF}" != "refs/heads/main" ]; then + echo "Release must run from main (Use workflow from: main). Got ${GITHUB_REF}." >&2 + exit 1 + fi + + - name: Require CI + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + python3 scripts/require_commit_checks.py \ + --repo "${{ github.repository }}" \ + --sha "$(git rev-parse HEAD)" \ + --timeout-seconds 1200 + + - name: Compute and push tag + id: tag + env: + ENVIRONMENT: ${{ github.event.inputs.environment }} + BUMP: ${{ github.event.inputs.bump }} + MESSAGE: ${{ github.event.inputs.message }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git fetch --tags origin + sha="$(git rev-parse HEAD)" + tag="$(git tag | python3 scripts/next_release_tag.py --environment "${ENVIRONMENT}" --bump "${BUMP}")" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${tag}" -m "${MESSAGE}" "${sha}" + git push origin "refs/tags/${tag}" + gh release create "${tag}" --target "${sha}" --notes "${MESSAGE}" --title "${tag}" + { + echo "tag=${tag}" + echo "sha=${sha}" + } >> "${GITHUB_OUTPUT}" + echo "Created ${tag} at ${sha}" + + deploy: + name: Deploy release + needs: cut + uses: ./.github/workflows/deploy.yaml + with: + environment: ${{ needs.cut.outputs.environment }} + ref: ${{ needs.cut.outputs.tag }} + secrets: inherit diff --git a/.platform/nginx/conf.d/01_upload_body_size.conf b/.platform/nginx/conf.d/01_upload_body_size.conf new file mode 100644 index 0000000..da18278 --- /dev/null +++ b/.platform/nginx/conf.d/01_upload_body_size.conf @@ -0,0 +1,6 @@ +# The Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m, +# which returned 413 for every media upload over ~1 MB before the request reached +# the API. The cap sits above the API's own request limit +# (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get +# the API's generic per-kind message instead of an nginx error page. +client_max_body_size 120M; diff --git a/AGENTS.md b/AGENTS.md index 03e95fe..1adc0e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,9 +6,7 @@ may add stricter backend rules but may never weaken the workspace baseline. ## Canonical governance documents (precedence) -1. `ARCHITECTURE_AND_CODE_QUALITY.md` — *what* the rules mean (canonical; - supersedes the older `BACKEND_ARCHITECTURE.md`, which is retained only as - historical reference). +1. `ARCHITECTURE_AND_CODE_QUALITY.md` — *what* the rules mean (canonical). 2. `QUALITY_GATES.md` — *how* rules are enforced (commands + CI mapping). 3. `REVIEW_AND_PR_FRAMEWORK.md` — *who* reviews, in what order, with what evidence. @@ -46,8 +44,9 @@ bash scripts/governance-check.sh ``` The command restores, verifies architecture and changed-file formatting, builds -Release, and runs the full test suite. CI (`architecture-quality` workflow) -calls the same script. See `QUALITY_GATES.md`. +Release, runs the full test suite, and validates Terraform. CI (`ci.yml`) +calls the same script from the `architecture` job (skipping G4/G5) and +aggregates results as `ci-complete`. See `QUALITY_GATES.md`. ## Hard rules (deviation needs an ADR; no wildcard suppressions) diff --git a/ARCHITECTURE_AND_CODE_QUALITY.md b/ARCHITECTURE_AND_CODE_QUALITY.md index 54b35a1..f032028 100644 --- a/ARCHITECTURE_AND_CODE_QUALITY.md +++ b/ARCHITECTURE_AND_CODE_QUALITY.md @@ -1,9 +1,8 @@ # Architecture and Code Quality (canonical) > **Status: canonical.** This document owns the *meaning* of the backend's -> architecture and code-quality rules. On any conflict with the older -> `BACKEND_ARCHITECTURE.md`, **this document governs**; that file is retained -> only as historical reference pending removal. +> architecture and code-quality rules. It replaced the earlier +> `BACKEND_ARCHITECTURE.md`, which now lives only in git history. > > Companion documents: > - `QUALITY_GATES.md` — *how* each rule is enforced (commands + CI mapping). diff --git a/Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs new file mode 100644 index 0000000..fad450d --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs @@ -0,0 +1,60 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class AccountOwnerDataServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task EnsureConfiguredOwnerAsync_MarksOnlyConfiguredUser() + { + await using var context = NewContext(); + context.Users.AddRange( + new ApplicationUser { Id = "old-owner", UserName = "old@example.com", IsAccountOwner = true }, + new ApplicationUser { Id = "configured-owner", UserName = "configured@example.com" }); + await context.SaveChangesAsync(); + + var service = new AccountOwnerDataService(context); + (await service.EnsureConfiguredOwnerAsync(" configured-owner ", CancellationToken.None)).Should().BeTrue(); + + (await context.Users.SingleAsync(user => user.Id == "old-owner")).IsAccountOwner.Should().BeFalse(); + (await context.Users.SingleAsync(user => user.Id == "configured-owner")).IsAccountOwner.Should().BeTrue(); + } + + [Fact] + public async Task EnsureConfiguredOwnerAsync_WithNoConfiguration_ClearsExistingOwner() + { + await using var context = NewContext(); + context.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner@example.com", IsAccountOwner = true }); + await context.SaveChangesAsync(); + + var service = new AccountOwnerDataService(context); + (await service.EnsureConfiguredOwnerAsync(null, CancellationToken.None)).Should().BeTrue(); + (await service.EnsureConfiguredOwnerAsync(" ", CancellationToken.None)).Should().BeFalse(); + + (await context.Users.SingleAsync()).IsAccountOwner.Should().BeFalse(); + } + + [Fact] + public async Task EnsureConfiguredOwnerAsync_RejectsUnknownUser() + { + await using var context = NewContext(); + var service = new AccountOwnerDataService(context); + + var action = () => service.EnsureConfiguredOwnerAsync("missing", CancellationToken.None); + + await action.Should().ThrowAsync() + .WithMessage("The configured account owner user was not found."); + } +} diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs index 99d5ab8..ac2d358 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs @@ -117,11 +117,11 @@ public class AuthenticationControllerTests { var service = new Mock(); service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny())) - .ReturnsAsync(true); + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Succeeded }); var controller = NewController(service, "42"); - var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None); + var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Newpassword = "new", Confirmpassword = "new" }, CancellationToken.None); var ok = result.Should().BeOfType().Subject; var response = ok.Value.Should().BeOfType().Subject; @@ -130,11 +130,11 @@ public class AuthenticationControllerTests } [Fact] - public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus() + public async Task ChangePassword_WrongCurrentPassword_ReturnsOldPasswordIncorrectStatus() { var service = new Mock(); service.Setup(s => s.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(false); + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.CurrentPasswordIncorrect }); var controller = NewController(service, "42"); @@ -143,6 +143,70 @@ public class AuthenticationControllerTests var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; response.Status.Should().Be("Old Password is incorrect"); + response.Message.Should().Be("Current password is incorrect"); + } + + [Fact] + public async Task ChangePassword_PolicyRejection_ReturnsPasswordRequirementsMessage() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "weak", It.IsAny())) + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.PasswordRejected }); + + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword( + new ChangePasswords { Currentpassword = "Current1!", Newpassword = "weak", Confirmpassword = "weak" }, + CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Password does not meet requirements"); + response.Message.Should().Be( + "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."); + } + + [Fact] + public async Task ChangePassword_ConfirmationMismatch_IsRejectedWithoutChangingThePassword() + { + var service = new Mock(); + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword( + new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@y" }, + CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + bad.Value.Should().BeOfType().Subject.Message.Should().Be("Passwords don't match"); + service.Verify( + s => s.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ChangePassword_NonPolicyFailure_ReturnsTheGenericMessage() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "Next2@x", It.IsAny())) + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Failed }); + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword( + new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@x" }, + CancellationToken.None); + + var response = result.Should().BeOfType().Subject.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Your password could not be changed. Try again."); + response.Message.Should().NotContain("at least 6 characters"); + } + + [Fact] + public void ChangePassword_RequiresAuthenticatedCaller() + { + var method = typeof(AuthenticationController).GetMethod(nameof(AuthenticationController.ChangePassword))!; + + method.GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: true) + .Should().NotBeEmpty(); } [Fact] @@ -168,35 +232,45 @@ public class AuthenticationControllerTests } [Fact] - public async Task ForgetPassword_Found_ReturnsCheckEmailMessage() + public async Task ForgetPassword_ReturnsTheSameSuccessWhetherOrNotTheEmailIsRegistered() { var service = new Mock(); - service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny())).ReturnsAsync(true); - var controller = NewController(service); - var result = await controller.ForgetPassword("a@b.com", CancellationToken.None); + var registered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, CancellationToken.None); + var unregistered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "x@y.com" }, CancellationToken.None); - var ok = result.Should().BeOfType().Subject; + var ok = registered.Should().BeOfType().Subject; var response = ok.Value.Should().BeOfType().Subject; response.Status.Should().Be("Success "); - response.Message.Should().Be("Please check your email for code"); + response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage); + Json(unregistered.Should().BeOfType().Subject.Value).Should().Be(Json(ok.Value)); + service.Verify(s => s.ForgetPasswordAsync("a@b.com", It.IsAny()), Times.Once); + service.Verify(s => s.ForgetPasswordAsync("x@y.com", It.IsAny()), Times.Once); } [Fact] - public async Task ForgetPassword_NotFound_ReturnsNoSuchEmailMessage() + public async Task ForgetPassword_WhenServiceThrows_StillAnswersTheSameSuccessAndLogsNoDetail() { var service = new Mock(); - service.Setup(s => s.ForgetPasswordAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + service.Setup(s => s.ForgetPasswordAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("SECRET a@b.com")); + var logger = new Mock>(); + logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var controller = new AuthenticationController(service.Object, logger.Object); - var controller = NewController(service); + var result = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, CancellationToken.None); - var result = await controller.ForgetPassword("x@y.com", CancellationToken.None); - - var bad = result.Should().BeOfType().Subject; - var response = bad.Value.Should().BeOfType().Subject; - response.Status.Should().Be("Error"); - response.Message.Should().Be("No such email is registered"); + var response = result.Should().BeOfType().Subject.Value.Should().BeOfType().Subject; + response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage); + logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((state, _) => !state.ToString()!.Contains("a@b.com") && !state.ToString()!.Contains("SECRET")), + null, + It.IsAny>()), + Times.Once); } [Theory] @@ -205,11 +279,11 @@ public class AuthenticationControllerTests public async Task VerificationCode_MapsServiceResult(bool matched, string expectedStatus, string expectedMessage) { var service = new Mock(); - service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny())).ReturnsAsync(matched); + service.Setup(s => s.VerifyCodeAsync("a@b.com", "123456", It.IsAny())).ReturnsAsync(matched); var controller = NewController(service); - var result = await controller.VerificationCode("123456", CancellationToken.None); + var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "123456" }, CancellationToken.None); if (matched) { @@ -227,6 +301,33 @@ public class AuthenticationControllerTests } } + [Fact] + public async Task VerificationCode_WithoutABody_PassesNoEmailOrCodeToTheService() + { + var service = new Mock(); + var controller = NewController(service); + + var result = await controller.VerificationCode(null, CancellationToken.None); + + result.Should().BeOfType().Subject.Value.Should().BeOfType() + .Which.Message.Should().Be("Code Not Matched"); + service.Verify(s => s.VerifyCodeAsync(null, null, It.IsAny()), Times.Once); + } + + [Fact] + public async Task VerificationCode_WhenServiceThrows_AnswersTheGenericWrongCodeError() + { + var service = new Mock(); + service.Setup(s => s.VerifyCodeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("SECRET-internal-stack-detail")); + var controller = NewController(service); + + var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "1" }, CancellationToken.None); + + Json(result.Should().BeOfType().Subject.Value) + .Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" })); + } + [Fact] public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage() { diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index 7d9aa08..9da31d9 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -6,6 +6,7 @@ using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using System.Security.Claims; @@ -18,14 +19,16 @@ public class AuthenticationServiceTests private static AuthenticationService NewService( Mock userData, Mock forget, - Mock email, + Mock email, out Mock> store, out Mock> hasher) { var (manager, s, h) = IdentityTestHelpers.CreateUserManager(); store = s; hasher = h; - return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object); + var jwtOptions = Microsoft.Extensions.Options.Options.Create(JwtOptions); + var sessionStamps = new SessionStampService(userData.Object, new InMemorySessionStampCache(TimeProvider.System), jwtOptions); + return new AuthenticationService(manager, jwtOptions, userData.Object, forget.Object, email.Object, new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System, sessionStamps); } private static JwtOptions JwtOptions => new() @@ -38,7 +41,7 @@ public class AuthenticationServiceTests [Fact] public async Task Login_UnknownUser_ReturnsNull() { - var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out _); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out _); store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ApplicationUser?)null); var result = await service.LoginAsync("nobody", "pw", CancellationToken.None); @@ -50,7 +53,7 @@ public class AuthenticationServiceTests public async Task Login_DeletedUser_RejectedBeforePasswordCheck() { var deletedUser = IdentityTestHelpers.User(isDeleted: true); - var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(deletedUser); store.Setup(s => s.GetRolesAsync(deletedUser, It.IsAny())).ReturnsAsync(new List()); @@ -64,7 +67,7 @@ public class AuthenticationServiceTests public async Task Login_ValidUser_ReturnsTokenFirstRoleAndIdentity() { var user = IdentityTestHelpers.User(); - var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); store.Setup(s => s.GetRolesAsync(user, It.IsAny())).ReturnsAsync(new List { "Admin", "Manager" }); store.As>() @@ -87,7 +90,7 @@ public class AuthenticationServiceTests public async Task Login_BadPassword_ReturnsNull() { var user = IdentityTestHelpers.User(); - var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); store.As>() .Setup(s => s.GetPasswordHashAsync(user, It.IsAny())).ReturnsAsync("hash"); @@ -98,74 +101,287 @@ public class AuthenticationServiceTests result.Should().BeNull(); } + private static byte[] ResetKey => PasswordResetCodeSecrets.DeriveKey(JwtOptions.Secret); + [Fact] - public async Task ForgetPassword_RegisteredEmail_ReplacesCodeAndSendsEmail() + public async Task ForgetPassword_RegisteredEmail_StoresOnlyAKeyedHashAndQueuesTheCode() { var user = IdentityTestHelpers.User(); var userData = new Mock(); userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(user); var forget = new Mock(); - var email = new Mock(); + var email = new Mock(); + string? stored = null, salt = null, body = null; + DateTime expires = default; + forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, h, s, e, _, _) => { stored = h; salt = s; expires = e; }) + .Returns(Task.CompletedTask); + email.Setup(e => e.TryEnqueue(user.Email!, "Forget Password Request.", It.IsAny())) + .Callback((_, _, b) => body = b) + .Returns(true); var service = NewService(userData, forget, email, out _, out _); + var before = DateTime.UtcNow; - var found = await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); + await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); - found.Should().BeTrue(); - forget.Verify( - f => f.ReplaceCodeAsync( - user.Email!, - user.Id, - It.Is(code => code.Length == 6 && code.All(char.IsDigit)), - It.IsAny()), - Times.Once); - email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is(b => b.Contains("Your Password Reset Code is:"))), Times.Once); + var code = System.Text.RegularExpressions.Regex.Match(body!, @"Your Password Reset Code is: (\d{6})").Groups[1].Value; + code.Should().HaveLength(6); + stored.Should().NotBe(code).And.MatchRegex("^[0-9a-f]{64}$"); + PasswordResetCodeSecrets.Matches(ResetKey, salt!, code, stored!).Should().BeTrue(); + expires.Should().BeCloseTo(before.AddMinutes(15), TimeSpan.FromSeconds(5)); } [Fact] - public async Task ForgetPassword_UnknownEmail_DoesNotEmailOrStoreCode() + public async Task ForgetPassword_UnknownEmail_MakesTheSameDataCallsAndQueuesNothing() { var userData = new Mock(); - userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ApplicationUser?)null); + userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(IdentityTestHelpers.User()); + var registered = new Mock(); + var unregistered = new Mock(); + var email = new Mock(); + email.Setup(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny())).Returns(true); + + await NewService(userData, registered, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None); + await NewService(userData, unregistered, email, out _, out _).ForgetPasswordAsync("nope@example.com", CancellationToken.None); + + registered.Invocations.Select(call => call.Method.Name) + .Should().Equal(unregistered.Invocations.Select(call => call.Method.Name)) + .And.Equal(nameof(IForgetPasswordDataService.ReplaceCodeAsync)); + unregistered.Verify(f => f.ReplaceCodeAsync("nope@example.com", string.Empty, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + email.Verify(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ForgetPassword_EmailThatCannotBeQueued_DoesNotCountTheRequest() + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); var forget = new Mock(); - var email = new Mock(); + var stored = new List<(string Hash, string Salt)>(); + forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, h, s, _, _, _) => stored.Add((h, s))) + .Returns(Task.CompletedTask); + var email = new Mock(); + var bodies = new List(); + // The queue is full for the first three requests. + email.Setup(e => e.TryEnqueue(user.Email!, It.IsAny(), It.IsAny())) + .Returns((_, _, b) => + { + bodies.Add(b); + return bodies.Count > 3; + }); var service = NewService(userData, forget, email, out _, out _); + for (var request = 0; request < 4; request++) + await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); - var found = await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None); - - found.Should().BeFalse(); - forget.Verify(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - email.Verify(e => e.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + // The three undelivered requests did not use up the hourly limit of three, and every + // email carries the code stored just before it was queued. + email.Verify(e => e.TryEnqueue(user.Email!, It.IsAny(), It.IsAny()), Times.Exactly(4)); + stored.Should().HaveCount(4); + for (var request = 0; request < 4; request++) + { + var code = System.Text.RegularExpressions.Regex.Match(bodies[request], @"Your Password Reset Code is: (\d{6})").Groups[1].Value; + PasswordResetCodeSecrets.Matches(ResetKey, stored[request].Salt, code, stored[request].Hash).Should().BeTrue(); + } + forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); } [Theory] - [InlineData(true)] [InlineData(false)] - public async Task VerifyCode_ForwardsDataServiceResult(bool exists) + [InlineData(true)] + public async Task ForgetPassword_WriteThatFailsOrIsCancelled_QueuesNoEmail(bool cancelled) { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); var forget = new Mock(); - forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny())).ReturnsAsync(exists); + forget.Setup(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))); + var email = new Mock(); + email.Setup(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny())).Returns(true); - var service = NewService(new Mock(), forget, new Mock(), out _, out _); + var act = () => NewService(userData, forget, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None); - var result = await service.VerifyCodeAsync("abc", CancellationToken.None); - - result.Should().Be(exists); + await act.Should().ThrowAsync(); + email.Verify(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] - public async Task ResetPassword_NoMatchingCode_ReturnsFalseWithoutReset() + public async Task ForgetPassword_EmailThatCannotBeQueued_MakesTheSameDataCallsAsAnUnknownEmail() + { + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(IdentityTestHelpers.User()); + var registered = new Mock(); + var unregistered = new Mock(); + var email = new Mock(); + email.Setup(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny())).Returns(false); + + await NewService(userData, registered, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None); + await NewService(userData, unregistered, email, out _, out _).ForgetPasswordAsync("nope@example.com", CancellationToken.None); + + registered.Invocations.Select(call => call.Method.Name) + .Should().Equal(unregistered.Invocations.Select(call => call.Method.Name)) + .And.Equal(nameof(IForgetPasswordDataService.ReplaceCodeAsync)); + } + + [Fact] + public async Task ForgetPassword_DeletedAccount_IsTreatedAsUnregistered() + { + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(IdentityTestHelpers.User(isDeleted: true)); + var forget = new Mock(); + var email = new Mock(); + + var service = NewService(userData, forget, email, out _, out _); + + await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); + + email.Verify(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(null, "123456")] + [InlineData("", "123456")] + [InlineData(" ", "123456")] + [InlineData("a@b.com", null)] + [InlineData("a@b.com", "")] + public async Task VerifyCode_WithoutEmailOrCode_FailsWithoutTouchingStoredCodes(string? emailAddress, string? code) + { + var forget = new Mock(MockBehavior.Strict); + + var service = NewService(new Mock(), forget, new Mock(), out _, out _); + + var result = await service.VerifyCodeAsync(emailAddress, code, CancellationToken.None); + + result.Should().BeFalse(); + } + + [Fact] + public async Task ResetPassword_NoPendingCodeForEmail_ReturnsFalseWithoutReset() { var forget = new Mock(); - forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(false); + forget.Setup(f => f.GetByEmailAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ForgetPasswordCode?)null); - var service = NewService(new Mock(), forget, new Mock(), out var store, out _); + var service = NewService(new Mock(), forget, new Mock(), out var store, out _); - var result = await service.ResetPasswordAsync("a@b.com", "999", "new", CancellationToken.None); + var result = await service.ResetPasswordAsync("a@b.com", "999999", "new", CancellationToken.None); result.Should().BeFalse(); store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); - forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + forget.Verify(f => f.TryConsumeAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ResetPassword_AttemptBudgetSpent_DeletesTheCodeAndFails() + { + var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456"), FailedAttempts = 5 }; + var forget = new Mock(); + forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny())).ReturnsAsync(pending); + forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var service = NewService(new Mock(), forget, new Mock(), out var store, out _); + + var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None); + + result.Should().BeFalse(); + forget.Verify(f => f.RemoveIssuedThroughAsync("a@b.com", 7, It.IsAny()), Times.Once); + store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task VerifyCode_FailedOrCancelledLookups_LeaveTheAccountCheckBudgetUntouched(bool cancelled) + { + var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456") }; + var forget = new Mock(); + var lookups = 0; + forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny())) + .Returns(() => ++lookups <= InMemoryPasswordResetThrottle.FailedChecksPerDay + ? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")) + : Task.FromResult(pending)); + forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny(), It.IsAny())).ReturnsAsync(true); + var service = NewService(new Mock(), forget, new Mock(), out _, out _); + + for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++) + { + var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + (await service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None)).Should().BeTrue(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task VerifyCode_FailedOrCancelledAttemptConsumes_LeaveTheAccountCheckBudgetUntouched(bool cancelled) + { + var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456") }; + var forget = new Mock(); + forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny())).ReturnsAsync(pending); + var consumes = 0; + forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny(), It.IsAny())) + .Returns(() => ++consumes <= InMemoryPasswordResetThrottle.FailedChecksPerDay + ? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")) + : Task.FromResult(true)); + var service = NewService(new Mock(), forget, new Mock(), out _, out _); + + for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++) + { + var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + (await service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None)).Should().BeTrue(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ForgetPassword_FailedOrCancelledWrites_DoNotUseUpTheEmailRequestLimit(bool cancelled) + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + var forget = new Mock(); + var writes = 0; + forget.Setup(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => ++writes <= InMemoryPasswordResetThrottle.CodeRequestsPerHour + ? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")) + : Task.CompletedTask); + var email = new Mock(); + email.Setup(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny())).Returns(true); + var service = NewService(userData, forget, email, out _, out _); + + for (var request = 0; request < InMemoryPasswordResetThrottle.CodeRequestsPerHour; request++) + { + var act = () => service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); + + writes.Should().Be(InMemoryPasswordResetThrottle.CodeRequestsPerHour + 1); + forget.Verify(f => f.PurgeExpiredAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("123456", "123456", true)] + [InlineData("123456", " 123456 ", true)] + [InlineData("123456", "123457", false)] + public void ResetCodeHash_IsSaltedAndComparedByValue(string issued, string candidate, bool expected) + { + var salt = PasswordResetCodeSecrets.NewSalt(); + var hash = PasswordResetCodeSecrets.Hash(ResetKey, salt, issued); + + PasswordResetCodeSecrets.Matches(ResetKey, salt, candidate, hash).Should().Be(expected); + PasswordResetCodeSecrets.Hash(ResetKey, PasswordResetCodeSecrets.NewSalt(), issued).Should().NotBe(hash); + PasswordResetCodeSecrets.Matches(ResetKey, "", issued, hash).Should().BeFalse(); + PasswordResetCodeSecrets.Matches(ResetKey, salt, issued, "").Should().BeFalse(); } } diff --git a/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs b/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs index bceb520..fbab136 100644 --- a/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs @@ -44,7 +44,7 @@ public class CalendarServiceTests StartDate = startDate, EndDate = startDate, IsDeleted = isDeleted, - CreatedDate = DateTime.Now + CreatedDate = DateTime.UtcNow }; ctx.Events.Add(ev); ctx.SaveChanges(); @@ -64,6 +64,7 @@ public class CalendarServiceTests saved.Title.Should().Be("Standup"); saved.IsDeleted.Should().Be(false); saved.CreatedDate.Should().NotBeNull(); + saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc); } [Fact] @@ -97,6 +98,7 @@ public class CalendarServiceTests var updated = ctx.Events.Single(); updated.Title.Should().Be("New"); updated.LastModificationTime.Should().NotBeNull(); + updated.LastModificationTime!.Value.Kind.Should().Be(DateTimeKind.Utc); } [Fact] @@ -134,6 +136,7 @@ public class CalendarServiceTests var row = ctx.Events.Single(); row.IsDeleted.Should().Be(true); row.DeletionTime.Should().NotBeNull(); + row.DeletionTime!.Value.Kind.Should().Be(DateTimeKind.Utc); } [Fact] diff --git a/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs new file mode 100644 index 0000000..ddd6082 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs @@ -0,0 +1,56 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class DashboardControllerTests +{ + private static object Prop(object source, string name) => + source.GetType().GetProperty(name)!.GetValue(source)!; + + private static DashboardController NewController(Mock service) => + new(service.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + + // Guards the Stats wire object, not the service DTO. GetKpiCountsAsync and + // DashboardStatsDTO already carried these three counts; the regression this + // pins is the controller's anonymous response silently dropping them, which + // is what left the tiles with nothing to read. + [Fact] + public async Task GetStats_SerialisesKpiCountsOnWireObject() + { + var service = new Mock(); + service.Setup(s => s.GetStatsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new DashboardStatsDTO + { + ScheduledTomorrow = 3, + PendingUplifts = 5, + AvetaPending = 7, + Unassigned = 11 + }); + + var result = await NewController(service).GetStats( + new DashboardStatsQueryDTO(), CancellationToken.None); + + var body = result.Should().BeOfType().Subject.Value!; + ((int)Prop(body, "scheduledTomorrow")).Should().Be(3); + ((int)Prop(body, "pendingUplifts")).Should().Be(5); + ((int)Prop(body, "avetaPending")).Should().Be(7); + ((int)Prop(body, "unassigned")).Should().Be(11); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 0af8f00..7358083 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -1,7 +1,12 @@ +using System.Security.Claims; using Data.SeaHavenIndustries; using FluentAssertions; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using Xunit; @@ -17,33 +22,994 @@ public class DashboardServiceTests return new ApplicationDbContext(options); } - private static DashboardService NewService(ApplicationDbContext ctx) => - new(new DashboardDataService(ctx)); + private static DashboardService NewService(ApplicationDbContext ctx) + { + var resolver = new WorkOrderAccountResolver( + new AccountDataService(ctx), + new LocationDataService(ctx)); + return new DashboardService(new DashboardDataService(ctx), resolver); + } - private static WorkOrder Wo(string? status, bool? isTemplate = false, string? assignTo = null) => - new() { Status = status, istemplate = isTemplate, AssignTo = assignTo }; + private static ClaimsPrincipal AccountUser( + int accountId, + string userId = "dispatcher-1", + string role = "Admin") + { + var claims = new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + + private static ClaimsPrincipal OrgWideUser() + { + var claims = new[] + { + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll), + new Claim(ClaimTypes.Role, "Admin") + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } [Fact] public async Task GetStatsAsync_CountsByStatusExcludingTemplates() { using var ctx = NewContext(); ctx.workOrders.AddRange( - Wo("Open", isTemplate: false), - Wo("Open", isTemplate: false, assignTo: "u1"), - Wo("In Progress"), - Wo("On Hold"), - Wo("Done"), - Wo("Done"), - Wo("Open", isTemplate: true), - Wo("Cancelled", isTemplate: false) + new WorkOrder { AccountId = 1, Status = "Open", istemplate = false }, + new WorkOrder { AccountId = 1, Status = "Open", istemplate = false, AssignTo = "u1" }, + new WorkOrder { AccountId = 1, Status = "In Progress" }, + new WorkOrder { AccountId = 1, Status = "On Hold" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Open", istemplate = true }, + new WorkOrder { AccountId = 1, Status = "Cancelled" }, + new WorkOrder { AccountId = 2, Status = "Open" }, + new WorkOrder { Status = "Open" } ); ctx.SaveChanges(); - var stats = await NewService(ctx).GetStatsAsync(CancellationToken.None); + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); stats.Total.Should().Be(7); stats.Open.Should().Be(4); stats.NotDispatched.Should().Be(1); stats.Completed.Should().Be(2); } + + [Fact] + public async Task GetStatsAsync_OrgWideUserSeesAllNonTemplateOrders() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open" }, + new WorkOrder { AccountId = 2, Status = "Done" }, + new WorkOrder { Status = "Open", istemplate = true }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + OrgWideUser(), new DashboardStatsQueryDTO(), CancellationToken.None); + + stats.Total.Should().Be(2); + stats.Open.Should().Be(1); + stats.Completed.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_ExposesKpiCounts_PendingUpliftsCountsPendingStatusOnly() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + var tomorrow = today.AddDays(1); + + // Scheduled-tomorrow tile: one account-1 order scheduled for tomorrow. + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = tomorrow.ToDateTime(TimeOnly.MinValue) + }); + + // Aveta-pending tile: Aveta-required order in the today/tomorrow window + // with no Aveta attachment. Its Dispatch anchors the uplift requests below. + var avetaOrder = new WorkOrder + { + AccountId = 1, + AvetaRequired = true, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue) + }; + ctx.workOrders.Add(avetaOrder); + ctx.SaveChanges(); + + var dispatch = new Dispatch { WorkOrderId = avetaOrder.Id, IsDeleted = false }; + ctx.Set().Add(dispatch); + ctx.SaveChanges(); + + // Pending-uplifts tile: only Status == "Pending" is an outstanding uplift + // awaiting an approval decision. This test pins the behaviour the code + // ships today: a "ChangesRequested" request has been sent back to the + // vendor, so it is NOT counted. Whether pendingUplifts should also include + // ChangesRequested is an open product call, not a settled review decision. + ctx.Set().AddRange( + new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", IsDeleted = false }, + new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "ChangesRequested", IsDeleted = false }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + stats.ScheduledTomorrow.Should().Be(1); + stats.AvetaPending.Should().Be(1); + stats.PendingUplifts.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_MissingScopeFailsClosed() + { + using var ctx = NewContext(); + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Role, "Dispatcher") + }, "test")); + + var act = () => NewService(ctx).GetStatsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); + + var exception = await act.Should().ThrowAsync(); + + exception.Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task GetStatsAsync_UsesDashboardMetricRulesForDateRange() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-6) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Emergency, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.InProgress, + ScheduledDate = today.AddDays(-3).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Reactive, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-15), + CompletedDate = today.AddDays(-12).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Incomplete, + ScheduledDate = today.AddDays(4).ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var query = new DashboardStatsQueryDTO + { + DateFrom = today.AddDays(-10), + DateTo = today.AddDays(10) + }; + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), query, CancellationToken.None); + + stats.Total.Should().Be(4); + stats.Breakdown.Overdue.Should().Be(0); + stats.Breakdown.Other.Should().Be(0); + stats.Breakdown.PM.Should().Be(2); + stats.Breakdown.Emergency.Should().Be(1); + stats.Breakdown.Reactive.Should().Be(1); + (stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive + + stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total); + stats.DueCount.Should().Be(3); + stats.CompletedDueCount.Should().Be(1); + stats.CompletionRate.Should().Be(33.33m); + stats.AverageResolutionDays.Should().Be(3m); + } + + [Fact] + public async Task GetStatsAsync_DispatcherOnlySeesAssignedOrders() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, role: "Dispatcher"), new DashboardStatsQueryDTO(), CancellationToken.None); + + stats.Total.Should().Be(1); + stats.Open.Should().Be(1); + } + + [Fact] + public async Task GetWorkloadAsync_UsesRoleScopeAndExcludesTerminalOrdersFromOpenCount() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole + { + Id = "dispatcher-role", + Name = "Dispatcher", + NormalizedName = "DISPATCHER" + }); + ctx.Users.AddRange( + new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" }, + new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" }); + ctx.UserRoles.AddRange( + new IdentityUserRole { UserId = "dispatcher-1", RoleId = "dispatcher-role" }, + new IdentityUserRole { UserId = "dispatcher-2", RoleId = "dispatcher-role" }); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" }, + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed }, + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-2", Status = "Open" }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetWorkloadAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.TotalDispatchers.Should().Be(2); + result.Items.Select(item => item.DispatcherName) + .Should().ContainInOrder("Ada One", "Ben Two"); + result.Items[0].TotalCount.Should().Be(2); + result.Items[0].OpenCount.Should().Be(1); + result.Items[1].TotalCount.Should().Be(0); + result.Items[1].OpenCount.Should().Be(0); + result.PageSize.Should().Be(10); + + var adminResult = await NewService(ctx).GetWorkloadAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + adminResult.TotalDispatchers.Should().Be(2); + adminResult.Items.Select(item => item.DispatcherName) + .Should().ContainInOrder("Ada One", "Ben Two"); + + var performance = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + performance.TotalDispatchers.Should().Be(2); + performance.Items[0].DispatcherName.Should().Be("Ada One"); + } + + [Fact] + public async Task GetPerformanceAsync_UsesDueOnlyRateAndOriginalDateResolution() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole + { + Id = "dispatcher-role", + Name = "Dispatcher", + NormalizedName = "DISPATCHER" + }); + ctx.Users.Add(new ApplicationUser + { + Id = "dispatcher-1", + FirstName = "Ada", + LastName = "One", + Color = "#123456" + }); + ctx.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = "dispatcher-role" + }); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-5), + CompletedDate = today.AddDays(-2).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.AddDays(3).ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.Items.Should().ContainSingle(); + result.Items[0].CompletionRate.Should().Be(100m); + result.Items[0].AverageResolutionDays.Should().Be(3m); + result.Items[0].Color.Should().Be("#123456"); + } + + [Fact] + public async Task GetRegionsAsync_UsesCanonicalBucketsAndUnmappedOther() + { + await using var ctx = NewContext(); + ctx.Locations.AddRange( + new Locations { Id = 1, State = "NY" }, + new Locations { Id = 2, State = "ca" }, + new Locations { Id = 3, State = "XX" }, + new Locations { Id = 4, State = "indiana" }, + new Locations { Id = 5, State = "NEW YORK" }, + new Locations { Id = 6, State = "California" }); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, LocationId = 1 }, + new WorkOrder { AccountId = 1, LocationId = 2 }, + new WorkOrder { AccountId = 1, LocationId = 3 }, + new WorkOrder { AccountId = 1, LocationId = 4 }, + new WorkOrder { AccountId = 1, LocationId = 5 }, + new WorkOrder { AccountId = 1, LocationId = 6 }, + new WorkOrder { AccountId = 1, LocationId = null }); + await ctx.SaveChangesAsync(); + + var result = await NewService(ctx).GetRegionsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + result.Items.Select(row => row.Region).Should().Equal( + "East", "Central", "West", "California", "Unmapped/Other"); + // NY + "NEW YORK" -> East; "indiana" -> Central; "ca" + "California" -> California; + // "XX" + unassigned location -> Unmapped/Other. Full-name storage forms must not + // fall through to Unmapped/Other (SH-348 review). + result.Items.Select(row => row.WorkOrderCount).Should().Equal(2, 1, 0, 2, 2); + } + + [Fact] + public void Resolve_MapsEveryUsStateAndItsFullNameToACanonicalBucket() + { + foreach (var code in UsStateCodes.All) + { + var byCode = DashboardRegions.Resolve(code); + byCode.Should().NotBe( + "Unmapped/Other", + $"state code {code} must belong to a canonical region"); + + var fullName = UsStateCodes.ExpandStorageValues(new[] { code }) + .First(value => value != code); + DashboardRegions.Resolve(fullName).Should().Be( + byCode, + $"the full-name storage form of {code} must resolve to the same region as the code"); + } + } + + [Fact] + public async Task GetVendorInsightsAsync_IsCompanyWideAndUsesVendorMetrics() + { + await using var ctx = NewContext(); + ctx.VendorCompanies.Add(new VendorCompany { Id = 10, Name = "Acme Services", IsDeleted = false }); + ctx.Vendors.Add(new Vendor + { + Id = 20, + CompanyId = 10, + IsActive = true, + CompanyName = "Acme Services" + }); + ctx.Dispatches.Add(new Dispatch { Id = 30, VendorId = 20 }); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + PrimaryDispatchId = 30, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-3), + CompletedDate = DateTime.UtcNow.Date.AddDays(-1), + RescheduleCount = 1 + }, + new WorkOrder + { + AccountId = 1, + PrimaryDispatchId = 30, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-2), + CompletedDate = DateTime.UtcNow.Date.AddDays(-1) + }); + await ctx.SaveChangesAsync(); + + var result = await NewService(ctx).GetVendorInsightsAsync( + AccountUser(1), CancellationToken.None); + + result.TotalVendors.Should().Be(1); + result.Items.Should().ContainSingle(); + result.Items[0].TotalJobs.Should().Be(2); + result.Items[0].CompletionRate.Should().Be(100); + result.Items[0].RescheduleRate.Should().Be(50); + } + + [Fact] + public async Task GetStatsAsync_DispatcherQuerySelectionCannotBroadenScope() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var query = new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }; + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None); + + stats.Total.Should().Be(2); + stats.Open.Should().Be(2); + } + + [Fact] + public async Task GetStatsAsync_AdminCanSelectIndividualDispatcherOrMine() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Done", AssignTo = "admin-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var service = NewService(ctx); + + var selected = await service.GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }, + CancellationToken.None); + selected.Total.Should().Be(3); + selected.Open.Should().Be(3); + + var mine = await service.GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "mine" }, + CancellationToken.None); + mine.Total.Should().Be(1); + mine.Completed.Should().Be(1); + + var all = await service.GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + all.Total.Should().Be(4); + } + + [Fact] + public async Task GetStatsAsync_ManagerSelectionScopesStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "manager-1", "Manager"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }, + CancellationToken.None); + + stats.Total.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_SchedulerSelectionScopesStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "scheduler-1", "Scheduler"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }, + CancellationToken.None); + + stats.Total.Should().Be(1); + } + + [Fact] + public async Task GetDashboardAsync_UnauthorizedRoleFailsClosed() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var user = AccountUser(1, "tech-1", "Technician"); + + var statsAct = () => service.GetStatsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); + var statsException = await statsAct.Should().ThrowAsync(); + statsException.Which.Code.Should().Be("Forbidden"); + + var workloadAct = () => service.GetWorkloadAsync( + user, new DashboardStatsQueryDTO(), 1, CancellationToken.None); + var workloadException = await workloadAct.Should().ThrowAsync(); + workloadException.Which.Code.Should().Be("Forbidden"); + + var performanceAct = () => service.GetPerformanceAsync( + user, new DashboardStatsQueryDTO(), 1, CancellationToken.None); + var performanceException = await performanceAct.Should().ThrowAsync(); + performanceException.Which.Code.Should().Be("Forbidden"); + + var regionsAct = () => service.GetRegionsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); + var regionsException = await regionsAct.Should().ThrowAsync(); + regionsException.Which.Code.Should().Be("Forbidden"); + + var trendAct = () => service.GetTrendAsync( + user, new DashboardTrendQueryDTO(), CancellationToken.None); + var trendException = await trendAct.Should().ThrowAsync(); + trendException.Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task GetWorkloadAsync_IndividualSelectionPreservesRosterWithZeroRows() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole + { + Id = "dispatcher-role", + Name = "Dispatcher", + NormalizedName = "DISPATCHER" + }); + ctx.Users.AddRange( + new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" }, + new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" }); + ctx.UserRoles.AddRange( + new IdentityUserRole { UserId = "dispatcher-1", RoleId = "dispatcher-role" }, + new IdentityUserRole { UserId = "dispatcher-2", RoleId = "dispatcher-role" }); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" }, + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" }); + ctx.SaveChanges(); + + var workload = await NewService(ctx).GetWorkloadAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" }, + 1, + CancellationToken.None); + + workload.TotalDispatchers.Should().Be(2); + workload.Items.Should().HaveCount(2); + workload.Items[0].DispatcherId.Should().Be("dispatcher-1"); + workload.Items[0].TotalCount.Should().Be(2); + workload.Items[0].OpenCount.Should().Be(2); + workload.Items[1].DispatcherId.Should().Be("dispatcher-2"); + workload.Items[1].TotalCount.Should().Be(0); + workload.Items[1].OpenCount.Should().Be(0); + workload.Page.Should().Be(1); + workload.PageSize.Should().Be(10); + + var performance = await NewService(ctx).GetPerformanceAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" }, + 1, + CancellationToken.None); + + performance.TotalDispatchers.Should().Be(2); + performance.Items.Should().HaveCount(2); + performance.Items[0].DispatcherId.Should().Be("dispatcher-1"); + performance.Items[0].AssignedCount.Should().Be(2); + performance.Items[1].DispatcherId.Should().Be("dispatcher-2"); + performance.Items[1].AssignedCount.Should().Be(0); + performance.Items[1].CompletedCount.Should().Be(0); + } + + [Fact] + public async Task GetPerformanceAsync_PopulatesAssignedAndCompletedCounts() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole + { + Id = "dispatcher-role", + Name = "Dispatcher", + NormalizedName = "DISPATCHER" + }); + ctx.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" }); + ctx.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = "dispatcher-role" + }); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled + }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.Items.Should().ContainSingle(); + result.Items[0].AssignedCount.Should().Be(3); + result.Items[0].CompletedCount.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_StatusDistributionBucketsResolvedStatuses() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled + }, + new WorkOrder { AccountId = 1, Status = "In Progress" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Unparseable" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + + stats.StatusDistribution.Should().HaveCount(4); + stats.StatusDistribution + .Select(bucket => (bucket.Status, bucket.Count)) + .Should().BeEquivalentTo(new[] + { + (Status: "Completed", Count: 2), + (Status: "In Progress", Count: 1), + (Status: "Scheduled", Count: 1), + (Status: "Unknown", Count: 1) + }); + stats.StatusDistribution.Sum(bucket => bucket.Count).Should().Be(5); + } + + [Fact] + public async Task GetStatsAsync_StatusDistributionIsEmptyWithoutWorkOrders() + { + using var ctx = NewContext(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + + stats.StatusDistribution.Should().BeEmpty(); + } + + [Fact] + public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate() + { + using var ctx = NewContext(); + // ScheduledDate is a stored calendar value (board writes persist `.Date`), so a work + // order buckets on its own calendar day regardless of the time-of-day component. The + // UTC hours below were previously shifted a full day by an America/New_York conversion + // (03:00 UTC -> prior evening), so they double as a regression guard: each row must now + // stay on the calendar date it was scheduled for. + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 15, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled, + ScheduledDate = new DateTime(2026, 1, 14, 15, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 1, 16, 15, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 16, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 17, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 13, 20, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = new DateOnly(2026, 1, 14), + DateTo = new DateOnly(2026, 1, 16) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Granularity.Should().Be("day"); + trend.Buckets.Select(bucket => bucket.Date).Should().Equal( + new DateOnly(2026, 1, 14), new DateOnly(2026, 1, 15), new DateOnly(2026, 1, 16)); + trend.Buckets.Select(bucket => bucket.Label).Should().Equal( + "2026-01-14", "2026-01-15", "2026-01-16"); + + // 01-14: only the canceled row scheduled that calendar day. + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets[0].Open.Should().Be(0); + trend.Buckets[0].Canceled.Should().Be(1); + trend.Buckets[0].Completed.Should().Be(0); + trend.Buckets[0].Overdue.Should().Be(0); + + // 01-15: the 03:00 UTC scheduled row (no longer shifted to 01-14) plus the completed row. + trend.Buckets[1].Total.Should().Be(2); + trend.Buckets[1].Open.Should().Be(1); + trend.Buckets[1].Completed.Should().Be(1); + trend.Buckets[1].Canceled.Should().Be(0); + trend.Buckets[1].Overdue.Should().Be(1); + + // 01-16: the 03:00 UTC scheduled row that stays on its own day. + trend.Buckets[2].Total.Should().Be(1); + trend.Buckets[2].Open.Should().Be(1); + trend.Buckets[2].Overdue.Should().Be(1); + + trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent); + } + + [Fact] + public async Task GetTrendAsync_MidnightScheduledDateStaysInTodayBucketAndIsNotOverdue() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + // A board-scheduled work order for today is stored as midnight (`.Date`). It must land + // in the Today bucket and count as open, not roll back to yesterday and read as overdue. + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = today.AddDays(-1), + DateTo = today.AddDays(1) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + var todayBucket = trend.Buckets.Single(bucket => bucket.Date == today); + todayBucket.Total.Should().Be(1); + todayBucket.Open.Should().Be(1); + todayBucket.Overdue.Should().Be(0); + + var yesterdayBucket = trend.Buckets.Single(bucket => bucket.Date == today.AddDays(-1)); + yesterdayBucket.Total.Should().Be(0); + } + + [Fact] + public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 16, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 9, 16, 12, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 9, 16, 18, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + Range = "3m", + DateFrom = new DateOnly(2026, 6, 17), + DateTo = new DateOnly(2026, 9, 16) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Granularity.Should().Be("week"); + trend.Buckets.Should().HaveCount(14); + trend.Buckets.Select(bucket => bucket.Date.DayOfWeek) + .Should().OnlyContain(day => day == DayOfWeek.Monday); + trend.Buckets[0].Date.Should().Be(new DateOnly(2026, 6, 15)); + trend.Buckets[0].Total.Should().Be(2); + trend.Buckets[0].Open.Should().Be(2); + trend.Buckets[^1].Date.Should().Be(new DateOnly(2026, 9, 14)); + trend.Buckets[^1].Total.Should().Be(1); + trend.Buckets[^1].Completed.Should().Be(1); + } + + [Fact] + public async Task GetTrendAsync_YearFilterUsesMonthlyBuckets() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 3, 20, 4, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 3, 21, 4, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc) + }, + // 2027-01-01 by calendar: outside the 2026 window. Previously an + // America/New_York conversion pulled it back to 2026-12-31 and into December. + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2027, 1, 1, 3, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), new DashboardTrendQueryDTO { Year = 2026 }, CancellationToken.None); + + trend.Granularity.Should().Be("month"); + trend.Buckets.Should().HaveCount(12); + trend.Buckets[0].Label.Should().Be("2026-01-01"); + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets[0].Open.Should().Be(1); + trend.Buckets[0].Overdue.Should().Be(1); + trend.Buckets[2].Label.Should().Be("2026-03-01"); + trend.Buckets[2].Total.Should().Be(1); + trend.Buckets[2].Completed.Should().Be(1); + // December stays empty: the 2027-01-01 row is no longer pulled inside the window. + trend.Buckets[11].Label.Should().Be("2026-12-01"); + trend.Buckets[11].Total.Should().Be(0); + trend.Buckets[11].Open.Should().Be(0); + trend.Today.Should().Be(DashboardBusinessTime.Today()); + } + + [Fact] + public async Task GetTrendAsync_MarksTodayBucketAsCurrent() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.AddDays(-2).ToDateTime(new TimeOnly(12, 0)) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = today.AddDays(-2), + DateTo = today.AddDays(2) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Today.Should().Be(today); + trend.Buckets.Should().HaveCount(5); + trend.Buckets.Count(bucket => bucket.IsCurrent).Should().Be(1); + trend.Buckets.Single(bucket => bucket.IsCurrent).Date.Should().Be(today); + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets.Single(bucket => bucket.Date == today).Total.Should().Be(0); + } + + [Fact] + public async Task GetTrendAsync_DispatcherRoleAndTenantScopeMatchStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-2", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 2, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = new DateOnly(2026, 2, 2), + DateTo = new DateOnly(2026, 2, 3) + }; + + var dispatcherTrend = await NewService(ctx).GetTrendAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None); + + dispatcherTrend.Buckets.Should().HaveCount(2); + dispatcherTrend.Buckets[0].Total.Should().Be(1); + dispatcherTrend.Buckets[1].Total.Should().Be(0); + + var adminTrend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + adminTrend.Buckets[0].Total.Should().Be(2); + + var otherAccountTrend = await NewService(ctx).GetTrendAsync( + AccountUser(2), query, CancellationToken.None); + + otherAccountTrend.Buckets[0].Total.Should().Be(1); + } } diff --git a/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs new file mode 100644 index 0000000..996b53c --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs @@ -0,0 +1,249 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// The Dashboard "Unassigned" count is open work orders with no +/// dispatcher in the selected period, and it equals the Work Orders list the +/// tile drills into (GET /board/search, Dispatcher = Unassigned). +/// +public class DashboardUnassignedTests +{ + private static readonly DateOnly From = new(2026, 9, 21); + private static readonly DateOnly To = new(2026, 9, 25); + + private static readonly List OpenStatuses = Enum.GetValues() + .Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled) + .ToList(); + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderAccountResolver Resolver(ApplicationDbContext ctx) + => new(new AccountDataService(ctx), new LocationDataService(ctx)); + + private static DashboardService NewDashboard(ApplicationDbContext ctx) + => new(new DashboardDataService(ctx), Resolver(ctx)); + + private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx) + => new(new WorkOrderAdvancedSearchDataService(ctx), Resolver(ctx)); + + private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin", string userId = "admin-1") + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) + }, "test")); + + private static WorkOrder Wo( + int id, + DateTime? scheduled, + string? assignTo = null, + LifecycleStatus? status = LifecycleStatus.Scheduled, + DateOnly? targetWeek = null, + int accountId = 1, + string? legacyStatus = null, + string? statusText = null) + => new() + { + Id = id, + AccountId = accountId, + InternalWONumber = $"2000000{id:0000}", + AssignTo = assignTo, + ScheduledDate = scheduled, + ScheduleWeekOnly = targetWeek.HasValue, + TargetWeek = targetWeek, + LifecycleStatus = status, + LegacyStatus = legacyStatus, + Status = statusText, + istemplate = false + }; + + // Rows written by legacy create paths, which set Status but no LifecycleStatus. + private static void SeedLegacy(ApplicationDbContext ctx) + { + var inRange = new DateTime(2026, 9, 22); + ctx.workOrders.AddRange( + Wo(13, inRange, status: null, statusText: "Open"), // counted + Wo(14, inRange, status: null, statusText: "On Hold"), // counted: Pending + Wo(15, inRange, status: null), // counted: no status at all + Wo(16, inRange, status: null, legacyStatus: "Done", statusText: "Open"), // closed: LegacyStatus wins + Wo(17, inRange, status: null, statusText: " Cancelled "), // canceled + Wo(18, inRange, assignTo: "disp-1", status: null, statusText: "Open"), // assigned + Wo(19, inRange, status: null, legacyStatus: "Open", statusText: "Done")); // counted: LegacyStatus wins + ctx.SaveChanges(); + } + + private static void Seed(ApplicationDbContext ctx) + { + ctx.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 22)), // counted + Wo(2, new DateTime(2026, 9, 23), assignTo: ""), // counted: blank assignee + Wo(3, null, targetWeek: new DateOnly(2026, 9, 21)), // counted: week-only in range + Wo(4, new DateTime(2026, 9, 24), assignTo: "disp-1"), // assigned + Wo(5, new DateTime(2026, 9, 24), status: LifecycleStatus.Completed), + Wo(6, new DateTime(2026, 9, 24), status: LifecycleStatus.Canceled), + Wo(7, new DateTime(2026, 8, 4)), // out of range + Wo(8, null, status: LifecycleStatus.Incomplete), // undated + Wo(9, new DateTime(2026, 9, 22), accountId: 2), // other tenant + Wo(10, null, status: LifecycleStatus.Incomplete, accountId: 2)); // other tenant, undated + ctx.SaveChanges(); + } + + [Fact] + public async Task GetStatsAsync_Range_CountsOpenUnassignedScheduledInPeriod() + { + using var ctx = NewContext(); + Seed(ctx); + + var stats = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None); + + stats.Unassigned.Should().Be(3); + } + + [Fact] + public async Task GetStatsAsync_AllTime_CountsEveryOpenUnassignedIncludingUndated() + { + using var ctx = NewContext(); + Seed(ctx); + + var stats = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + // 1, 2, 3, 7 (out of any week but open) and 8 (undated). + stats.Unassigned.Should().Be(5); + } + + [Fact] + public async Task GetStatsAsync_CountsOpenLegacyRowsWithNoLifecycleStatus() + { + using var ctx = NewContext(); + Seed(ctx); + SeedLegacy(ctx); + + var inRange = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None); + var allTime = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + // 1, 2, 3 plus legacy 13, 14, 15 and 19. + inRange.Unassigned.Should().Be(7); + allTime.Unassigned.Should().Be(9); + } + + [Fact] + public async Task BoardSearch_StatusFilter_MatchesLegacyRowsByTheirLegacyStatus() + { + using var ctx = NewContext(); + var inRange = new DateTime(2026, 9, 22); + ctx.workOrders.AddRange( + Wo(1, inRange), + Wo(2, inRange, status: null, statusText: "scheduled"), + Wo(3, inRange, status: null, statusText: "Open"), + Wo(4, inRange, status: null, statusText: "Legacy Mystery"), + Wo(5, inRange, status: null)); + ctx.SaveChanges(); + var user = AccountUser(1); + + async Task> Ids(LifecycleStatus status) + => (await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = From, + DateTo = To, + Statuses = new List { status }, + PageSize = 200 + }, user, "admin-1")).Items.Select(row => row.Id); + + (await Ids(LifecycleStatus.Scheduled)).Should().BeEquivalentTo(new[] { 1, 2 }); + // Unknown or missing legacy text reads as Incomplete, as the Phase0 backfill set it. + (await Ids(LifecycleStatus.Incomplete)).Should().BeEquivalentTo(new[] { 3, 4, 5 }); + (await Ids(LifecycleStatus.Completed)).Should().BeEmpty(); + } + + [Fact] + public async Task GetStatsAsync_Unassigned_NeverCountsAnotherTenantsWorkOrders() + { + using var ctx = NewContext(); + Seed(ctx); + + var accountTwo = await NewDashboard(ctx).GetStatsAsync( + AccountUser(2), new DashboardStatsQueryDTO(), CancellationToken.None); + var accountTwoInRange = await NewDashboard(ctx).GetStatsAsync( + AccountUser(2), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None); + + accountTwo.Unassigned.Should().Be(2); + accountTwoInRange.Unassigned.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_DispatcherScope_HasNoUnassignedWork() + { + using var ctx = NewContext(); + Seed(ctx); + + var dispatcher = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1, role: "Dispatcher", userId: "disp-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + var adminPickingDispatcher = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), + new DashboardStatsQueryDTO { DispatcherId = "disp-1" }, + CancellationToken.None); + + dispatcher.Unassigned.Should().Be(0); + adminPickingDispatcher.Unassigned.Should().Be(0); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetStatsAsync_UnassignedMatchesTheDrillDownList(bool withRange) + { + using var ctx = NewContext(); + Seed(ctx); + SeedLegacy(ctx); + // Dated outside the board's all-weeks window: neither side may count them. + ctx.workOrders.AddRange(Wo(11, new DateTime(1999, 12, 31)), Wo(12, new DateTime(2100, 1, 1))); + ctx.SaveChanges(); + var user = AccountUser(1); + + var stats = await NewDashboard(ctx).GetStatsAsync( + user, + withRange ? new DashboardStatsQueryDTO { DateFrom = From, DateTo = To } : new DashboardStatsQueryDTO(), + CancellationToken.None); + + // What the Work Orders board sends for the tile's drill-down link. + var list = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = withRange ? From : new DateOnly(2000, 1, 1), + DateTo = withRange ? To : new DateOnly(2099, 12, 31), + IncludeDateless = !withRange, + Dispatchers = new List { "__unassigned__" }, + Statuses = OpenStatuses, + PageSize = 200 + }, user, "admin-1"); + + stats.Unassigned.Should().Be(list.TotalCount); + var listed = list.Items.Select(row => row.Id).ToList(); + listed.Should().Contain(new[] { 13, 14, 15, 19 }); + listed.Should().NotContain(new[] { 16, 17, 18 }); + } +} diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs index b926519..ebd3a74 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs @@ -31,7 +31,9 @@ public class LocationControllerSitesTests dataService, new AccountDataService(ctx), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of(), + new SeaHaven.Services.Implementation.TeamPermissionPolicy()); var controller = new LocationController(service, Mock.Of>()) { diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs index f8f3487..a1dc2c3 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs @@ -4,6 +4,7 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using FluentAssertions; using FluentValidation; +using FluentValidation.Results; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; @@ -25,7 +26,7 @@ public class LocationControllerTests public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData() { var service = new Mock(); - service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", It.IsAny())) + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new PagedResult { Items = new List { new() { Id = 1, Name = "Site" } }, @@ -34,7 +35,7 @@ public class LocationControllerTests PageSize = 10 }); - var result = await NewController(service).GetLocationList("a", 1, 10, CancellationToken.None); + var result = await NewController(service).GetLocationList("a", 1, 10, cancellationToken: CancellationToken.None); var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; @@ -44,6 +45,43 @@ public class LocationControllerTests envelope.TotalPages.Should().Be(1); } + [Fact] + public async Task GetLocationList_PassesStatesFilterToService() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedResult + { + Items = new List(), + TotalCount = 0, + Page = 1, + PageSize = 10 + }); + + var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", cancellationToken: CancellationToken.None); + + result.Should().BeOfType(); + service.VerifyAll(); + } + + [Fact] + public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new ValidationException(new[] + { + new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.") + })); + + var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", cancellationToken: CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Validation Error"); + response.Message.Should().Contain("ZZ"); + } + [Fact] public async Task GetLocationById_WhenMissing_ReturnsNotFoundAndDoesNotLeakEntity() { @@ -162,4 +200,166 @@ public class LocationControllerTests var notFound = result.Should().BeOfType().Subject; notFound.Value.Should().BeOfType().Subject.Message.Should().Be("Location not found"); } + + [Fact] + public async Task GetLocationList_ForwardsSortParamsToService() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), "city", "desc")) + .ReturnsAsync(new PagedResult { Items = new List(), TotalCount = 0, Page = 1, PageSize = 10 }); + + await NewController(service).GetLocationList(sortBy: "city", sortDirection: "desc", cancellationToken: CancellationToken.None); + + service.Verify(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), "city", "desc"), Times.Once); + } + + [Fact] + public async Task GetLocationList_WhenSortInvalid_ReturnsExistingValidationErrorShape() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new ValidationException(new[] + { + new ValidationFailure("sortBy", "sortBy must be one of: code, client, address, city, state, poc.") + })); + + var result = await NewController(service).GetLocationList(sortBy: "nope", cancellationToken: CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Validation Error"); + response.Message.Should().Contain("sortBy"); + } + + [Fact] + public async Task GetLocationList_ProjectsClientAccountNameAndSiteFields() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), null, null)) + .ReturnsAsync(new PagedResult + { + Items = new List + { + new() + { + Id = 1, + Name = "STL8", + Address1 = "9 River Rd", + City = "St. Louis", + State = "MO", + Zip = "63101", + PhoneNumber = "555-0100", + Email = "poc@example.com", + AccountId = 3, + AccountName = "Acme Co", + Contacts = new List + { + new() { Id = 10, Name = "Cara Lane", Phone = "555-0100" }, + new() { Id = 11, Name = "Alan Ford", Phone = "555-0101" } + } + } + }, + TotalCount = 1, + Page = 1, + PageSize = 10 + }); + + var result = await NewController(service).GetLocationList(cancellationToken: CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + using var json = System.Text.Json.JsonSerializer.SerializeToDocument(ok.Value); + var row = json.RootElement.GetProperty("Data").EnumerateArray().Single(); + + row.GetProperty("Name").GetString().Should().Be("STL8", "the site code is the legacy Name field"); + row.GetProperty("Address").GetString().Should().Be("9 River Rd"); + row.GetProperty("City").GetString().Should().Be("St. Louis"); + row.GetProperty("State").GetString().Should().Be("MO"); + row.GetProperty("ZipCode").GetString().Should().Be("63101"); + row.GetProperty("Phone").GetString().Should().Be("555-0100"); + row.GetProperty("ContactEmail").GetString().Should().Be("poc@example.com"); + row.GetProperty("Contact").GetString().Should().Be("Cara Lane", "the first ordered contact is the legacy Contact field"); + row.GetProperty("AccountId").GetInt32().Should().Be(3); + row.GetProperty("ClientName").GetString().Should().Be("Acme Co"); + row.GetProperty("AccountName").GetString().Should().Be("Acme Co"); + var contacts = row.GetProperty("Contacts"); + contacts.GetArrayLength().Should().Be(2); + contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane"); + contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford"); + } + + [Fact] + public async Task AddLocation_DuplicateSiteCode_Returns409WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException()); + + var result = await NewController(service).AddLocation(new Location_DTO { Name = "BK5" }, CancellationToken.None); + + var conflict = result.Should().BeOfType().Subject; + Prop(conflict.Value!, "Code").Should().Be("DuplicateSiteCode"); + Prop(conflict.Value!, "Message").Should().Be("This site code already exists."); + } + + [Fact] + public async Task EditLocation_DuplicateSiteCode_Returns409() + { + var service = new Mock(); + service.Setup(s => s.UpdateLocationFromRequestAsync(4, It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException()); + + var result = await NewController(service).EditLocation(4, new EditLocation_DTO { Name = "BK5" }, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task DeleteLocation_WithoutPermission_Returns403WithDeleteMessage() + { + var service = new Mock(); + service.Setup(s => s.DeleteLocationByIdAsync(4, It.IsAny(), It.IsAny())) + .ThrowsAsync(new SeaHaven.Services.Exceptions.SiteForbiddenException( + SeaHaven.Services.Exceptions.SiteForbiddenException.DeleteDeniedMessage)); + + var result = await NewController(service).DeleteLocation(4, CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(403); + forbidden.Value.Should().BeOfType().Which.Message.Should().Be("You are not allowed to delete sites."); + } + + [Fact] + public async Task GetOpenWorkOrders_ReturnsCountAndIds_Or404() + { + var service = new Mock(); + service.Setup(s => s.GetOpenWorkOrdersAsync(4, It.IsAny(), It.IsAny())) + .ReturnsAsync(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } }); + + var ok = (await NewController(service).GetOpenWorkOrders(4, CancellationToken.None)) + .Should().BeOfType().Subject; + ok.Value.Should().BeEquivalentTo(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } }); + + (await NewController(service).GetOpenWorkOrders(5, CancellationToken.None)) + .Should().BeOfType(); + } + + [Fact] + public async Task UpdateSiteContactInfo_MapsContactsAndNotesToTheService() + { + var service = new Mock(); + SiteContactInfoRequestDTO? seen = null; + service.Setup(s => s.UpdateSiteContactInfoAsync(4, It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, request, _, _) => seen = request) + .Returns(Task.CompletedTask); + + var result = await NewController(service).UpdateSiteContactInfo(4, new SiteContactInfoInput_DTO + { + Contacts = new List { new() { Id = 7, Name = "Main", Phone = "555-0100" } }, + Notes = "Gate 4" + }, CancellationToken.None); + + result.Should().BeOfType(); + seen!.Notes.Should().Be("Gate 4"); + seen.Contacts.Should().ContainSingle().Which.Should().BeEquivalentTo(new SiteContactRequestDTO { Id = 7, Name = "Main", Phone = "555-0100" }); + } } diff --git a/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs new file mode 100644 index 0000000..595f78e --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs @@ -0,0 +1,214 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class LocationDataServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static async Task SeedAsync(ApplicationDbContext ctx) + { + ctx.Locations.AddRange( + new Locations { Id = 1, Name = "Alpha Site", City = "Austin", State = "TX" }, + new Locations { Id = 2, Name = "Beta Site", City = "Dallas", State = "TX" }, + new Locations { Id = 3, Name = "Gamma Yard", City = "Kansas City", State = "MO" }, + new Locations { Id = 4, Name = "Delta Hub", City = "Indianapolis", State = "indiana" }, + new Locations { Id = 5, Name = "Epsilon Depot", City = "Houston", State = "TX" }, + new Locations { Id = 6, Name = "Zeta Lot", City = "Nowhere", State = null }); + await ctx.SaveChangesAsync(); + } + + [Fact] + public async Task GetListPagedAsync_WithoutStates_ReturnsAllRows() + { + await using var ctx = NewContext(); + await SeedAsync(ctx); + + var (items, totalCount) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None); + + totalCount.Should().Be(6); + items.Should().HaveCount(6); + } + + [Fact] + public async Task GetListPagedAsync_AppliesStatesFilterBeforeCountAndPaging() + { + await using var ctx = NewContext(); + await SeedAsync(ctx); + + var (page1, totalCount) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 2, null, new[] { "TX" }, CancellationToken.None); + + totalCount.Should().Be(3, "count must reflect the state filter, not the whole table"); + page1.Should().HaveCount(2); + page1.Select(l => l.Name).Should().ContainInOrder("Alpha Site", "Beta Site"); + + var (page2, totalCountAgain) = await new LocationDataService(ctx) + .GetListPagedAsync(2, 2, null, new[] { "TX" }, CancellationToken.None); + + totalCountAgain.Should().Be(3); + page2.Select(l => l.Name).Should().ContainSingle("Epsilon Depot"); + } + + [Fact] + public async Task GetListPagedAsync_MultipleStates_KeepSearchFilter() + { + await using var ctx = NewContext(); + await SeedAsync(ctx); + + var (items, totalCount) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, "Dallas", new[] { "TX", "MO" }, CancellationToken.None); + + totalCount.Should().Be(1); + items.Should().ContainSingle().Which.Name.Should().Be("Beta Site"); + } + + [Fact] + public async Task GetListPagedAsync_StatesFilter_ExcludesNullAndNonMatchingStates() + { + await using var ctx = NewContext(); + await SeedAsync(ctx); + + var (items, totalCount) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, new[] { "IN", "INDIANA", "MO", "MISSOURI" }, CancellationToken.None); + + totalCount.Should().Be(2); + items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO"); + } + + private static async Task SeedRegistryAsync(ApplicationDbContext ctx) + { + ctx.Accounts.AddRange( + new Accounts { Id = 1, Name = "Acme Co", IsDeleted = false }, + new Accounts { Id = 2, Name = "Borealis LLC", IsDeleted = false }); + ctx.Locations.AddRange( + new Locations { Id = 10, Name = "STL8", Address1 = "9 River Rd", City = "St. Louis", State = "MO", AccountId = 1 }, + new Locations { Id = 11, Name = "DFW8", Address1 = "2 Prairie Ave", City = "Dallas", State = "TX", AccountId = 2 }, + new Locations { Id = 12, Name = "IND9", Address1 = "5 Circle Blvd", City = "Indianapolis", State = "IN", AccountId = 1 }, + new Locations { Id = 13, Name = "MIA2", Address1 = "1 Bay Dr", City = "Miami", State = "FL", AccountId = 2 }); + ctx.Contacts.AddRange( + new Contacts { Id = 100, LocationId = 10, SiteContactOrder = 0, FirstName = "Cara Lane", PhoneNumber = "555-0001" }, + new Contacts { Id = 101, LocationId = 10, SiteContactOrder = 1, FirstName = "Alan Ford", PhoneNumber = "555-0002" }, + new Contacts { Id = 102, LocationId = 11, SiteContactOrder = 1, FirstName = "Zoe Park", PhoneNumber = "555-0003" }, + new Contacts { Id = 103, LocationId = 11, SiteContactOrder = 0, FirstName = "Removed Poc", PhoneNumber = "555-0004", IsDeleted = true }, + new Contacts { Id = 104, LocationId = 12, SiteContactOrder = 0, FirstName = "Bob Quinn", PhoneNumber = "555-0005" }); + await ctx.SaveChangesAsync(); + } + + [Theory] + [InlineData("STL", new[] { 10 }, "site code")] + [InlineData("Borealis", new[] { 11, 13 }, "client name")] + [InlineData("Prairie", new[] { 11 }, "street address")] + [InlineData("Miami", new[] { 13 }, "city")] + public async Task GetListPagedAsync_SearchMatchesCodeClientAddressAndCity(string term, int[] expectedIds, string becauseField) + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, totalCount) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, term, null, CancellationToken.None); + + totalCount.Should().Be(expectedIds.Length, $"search must match the {becauseField}"); + items.Select(l => l.Id).Should().BeEquivalentTo(expectedIds); + } + + [Theory] + [InlineData("code", 11, 12, 13, 10)] + [InlineData("client", 10, 12, 11, 13)] + [InlineData("address", 13, 11, 12, 10)] + [InlineData("city", 11, 12, 13, 10)] + [InlineData("state", 13, 12, 10, 11)] + [InlineData("poc", 13, 12, 10, 11)] + public async Task GetListPagedAsync_SortByAllowlistedColumn_OrdersAscending_WithIdTiebreak( + string sortBy, params int[] expectedIds) + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, sortBy, "asc"); + + items.Select(l => l.Id).Should().ContainInOrder(expectedIds); + } + + [Fact] + public async Task GetListPagedAsync_SortByPoc_IgnoresDeletedContacts_AndSortsByFirstActiveContact() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, "poc", "asc"); + + // DFW8's order-0 row is soft deleted, so its POC is "Zoe Park" (order 1) and it must + // sort last; including the deleted row ("Removed Poc") would instead place it second. + items.Select(l => l.Id).Should().ContainInOrder(13, 12, 10, 11); + } + + [Fact] + public async Task GetListPagedAsync_SortByClient_Desc_ReversesAccountOrder() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, "client", "desc"); + + items.Select(l => l.Id).Should().ContainInOrder(new[] { 11, 13, 10, 12 }); + } + + [Fact] + public async Task GetListPagedAsync_UnknownSortColumn_FallsBackToLegacyCodeOrder() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, "not-a-column", "sideways"); + + items.Select(l => l.Name).Should().ContainInOrder("DFW8", "IND9", "MIA2", "STL8"); + } + + [Fact] + public async Task GetListPagedAsync_EqualSortValues_BreakTiesById_AcrossPages() + { + await using var ctx = NewContext(); + ctx.Locations.AddRange( + new Locations { Id = 20, Name = "HOU1", City = "Dallas" }, + new Locations { Id = 21, Name = "AUS3", City = "Dallas" }, + new Locations { Id = 22, Name = "ELP4", City = "Dallas" }); + await ctx.SaveChangesAsync(); + + var service = new LocationDataService(ctx); + + var (page1, totalCount) = await service.GetListPagedAsync(1, 2, null, null, CancellationToken.None, "city", "asc"); + var (page2, _) = await service.GetListPagedAsync(2, 2, null, null, CancellationToken.None, "city", "asc"); + + totalCount.Should().Be(3); + page1.Select(l => l.Id).Should().ContainInOrder(20, 21); + page2.Select(l => l.Id).Should().ContainInOrder(22); + } + + [Fact] + public async Task GetListPagedAsync_ListLoadIncludesAccountNameForProjection() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None); + + items.Single(l => l.Id == 10).Account!.Name.Should().Be("Acme Co"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index 838526d..2a3f17c 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -15,6 +15,27 @@ namespace Api.SeaHavenIndustries.Tests; public class LocationServiceTests { + /// Fills the fields a new site must carry (client, address, one contact) unless the test set them. + private static LocationCreateRequestDTO WithSiteFields(ApplicationDbContext ctx, LocationCreateRequestDTO request) + { + if (request.AccountId == null) + { + if (!ctx.Accounts.Any(a => a.Id == 7)) + { + ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false }); + ctx.SaveChanges(); + } + + request.AccountId = 7; + } + + request.Address ??= "1 Depot Rd"; + request.City ??= "Dallas"; + request.State ??= "TX"; + request.Contacts ??= new List { new() { Name = "Main", Phone = "555-0100" } }; + return request; + } + private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() @@ -28,7 +49,9 @@ public class LocationServiceTests new LocationDataService(ctx), new AccountDataService(ctx), new CreateLocationValidation(), - new UpdateLocationValidation()); + new UpdateLocationValidation(), + Mock.Of(), + new SeaHaven.Services.Implementation.TeamPermissionPolicy()); private static void SeedAccount(ApplicationDbContext ctx, int id, string name = "Customer") { @@ -83,7 +106,7 @@ public class LocationServiceTests SeedAccount(ctx, 9); var service = NewService(ctx); - await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO + await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", Title = "Main WH", @@ -95,7 +118,7 @@ public class LocationServiceTests ContactEmail = "wh@example.com", Status = "Active", AccountId = 9 - }, OrgWideAdmin(), CancellationToken.None); + }), OrgWideAdmin(), CancellationToken.None); var entity = ctx.Locations.Single(); entity.Name.Should().Be("Warehouse"); @@ -118,7 +141,7 @@ public class LocationServiceTests SeedLocation(ctx, "Beta Site", "Dallas"); SeedLocation(ctx, "Gamma Yard", "Austin"); - var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", CancellationToken.None); + var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", cancellationToken: CancellationToken.None); page.Items.Should().HaveCount(2); page.TotalCount.Should().Be(2); @@ -126,6 +149,95 @@ public class LocationServiceTests page.Items.Select(l => l.Name).Should().NotBeNull(); } + [Fact] + public async Task GetLocationListPagedAsync_NormalizesStates_CaseWhitespaceAndDuplicates() + { + var data = new Mock(); + IReadOnlyCollection? captured = default; + data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states) + .ReturnsAsync((new List(), 0)); + + await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None); + + captured.Should().NotBeNull(); + captured.Should().BeEquivalentTo(new[] { "TX", "TEXAS", "MO", "MISSOURI" }); + captured.Should().HaveCount(4); + } + + [Fact] + public async Task GetLocationListPagedAsync_EmptyOrWhitespaceStates_PassesNoStateFilter() + { + foreach (var states in new[] { null, "", " ", " , , " }) + { + var data = new Mock(); + IReadOnlyCollection? captured = default; + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter) + .ReturnsAsync((new List(), 0)); + + var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None); + + captured.Should().BeNull($"states input '{states}' must mean no filter"); + page.Should().NotBeNull(); + } + } + + [Fact] + public async Task GetLocationListPagedAsync_InvalidStates_ThrowsValidationAndNeverQueries() + { + var data = new Mock(); + + var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, "TX, zz, QQ", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => + e.PropertyName == "states" + && e.ErrorMessage.Contains("ZZ") + && e.ErrorMessage.Contains("QQ") + && !e.ErrorMessage.Contains("TX")); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes() + { + var data = new Mock(); + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((new List(), 0)); + + var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All); + + var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, allStates, CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task GetLocationListPagedAsync_AppliesNormalizedStatesThroughDataService() + { + using var ctx = NewContext(); + SeedLocation(ctx, "Alpha Site", "Austin").State = "TX"; + SeedLocation(ctx, "Beta Site", "Dallas").State = "TX"; + SeedLocation(ctx, "Gamma Yard", "Kansas City").State = "MO"; + SeedLocation(ctx, "Delta Hub", "Indianapolis").State = "IN"; + await ctx.SaveChangesAsync(); + + var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, " mo , tx ", CancellationToken.None); + + page.TotalCount.Should().Be(3); + page.Items.Select(l => l.Name).Should().BeEquivalentTo("Alpha Site", "Beta Site", "Gamma Yard"); + } + + private static LocationService NewServiceWithSpy(ILocationDataService dataService) => + new( + dataService, + Mock.Of(), + new CreateLocationValidation(), + new UpdateLocationValidation(), + Mock.Of(), + new SeaHaven.Services.Implementation.TeamPermissionPolicy()); + [Fact] public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull() { @@ -150,14 +262,14 @@ public class LocationServiceTests await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO { - Name = "New", + Name = "Old", Address = "9 New St", City = "Plano", Status = "Inactive" }, OrgWideAdmin(), CancellationToken.None); var row = ctx.Locations.Single(); - row.Name.Should().Be("New"); + row.Name.Should().Be("Old", "the site code is immutable"); row.Address1.Should().Be("9 New St"); row.City.Should().Be("Plano"); row.Status.Should().Be("Inactive"); @@ -176,7 +288,7 @@ public class LocationServiceTests await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO { - Name = "New", + Name = "Old", City = "Plano" }, OrgWideAdmin(), CancellationToken.None); @@ -214,12 +326,12 @@ public class LocationServiceTests await NewService(ctx).UpdateLocationFromRequestAsync( existing.Id, - new LocationUpdateRequestDTO { Name = "Renamed", City = "Austin" }, + new LocationUpdateRequestDTO { Name = "Owned", City = "Austin" }, AccountUser(4), CancellationToken.None); var row = ctx.Locations.Single(); - row.Name.Should().Be("Renamed"); + row.Name.Should().Be("Owned"); row.City.Should().Be("Austin"); row.AccountId.Should().Be(4); } @@ -299,7 +411,7 @@ public class LocationServiceTests using var ctx = NewContext(); var act = () => NewService(ctx).CreateLocationFromRequestAsync( - new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }, + WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }), OrgWideAdmin(), CancellationToken.None); @@ -315,7 +427,7 @@ public class LocationServiceTests ctx.SaveChanges(); var act = () => NewService(ctx).CreateLocationFromRequestAsync( - new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 }, + WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 }), OrgWideAdmin(), CancellationToken.None); @@ -331,7 +443,7 @@ public class LocationServiceTests SeedAccount(ctx, 99); var act = () => NewService(ctx).CreateLocationFromRequestAsync( - new LocationCreateRequestDTO { Name = "Site", AccountId = 99 }, + WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 99 }), AccountUser(4), CancellationToken.None); @@ -366,7 +478,7 @@ public class LocationServiceTests SeedAccount(ctx, 9); var act = () => NewService(ctx).CreateLocationFromRequestAsync( - new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }, + WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }), MissingScope(), CancellationToken.None); @@ -389,12 +501,14 @@ public class LocationServiceTests new LocationDataService(ctx), accounts.Object, new CreateLocationValidation(), - new UpdateLocationValidation()); + new UpdateLocationValidation(), + Mock.Of(), + new SeaHaven.Services.Implementation.TeamPermissionPolicy()); using var cts = new CancellationTokenSource(); await service.CreateLocationFromRequestAsync( - new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }, + WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }), OrgWideAdmin(), cts.Token); @@ -434,16 +548,95 @@ public class LocationServiceTests } [Fact] - public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing() + public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService() + { + var data = new Mock(); + string? capturedSort = "sentinel"; + string? capturedDirection = "sentinel"; + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) => + { + capturedSort = sortBy; + capturedDirection = sortDirection; + }) + .ReturnsAsync((new List(), 0)); + + await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: " POC ", sortDirection: " DESC "); + + capturedSort.Should().Be("poc"); + capturedDirection.Should().Be("desc"); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", " ")] + public async Task GetLocationListPagedAsync_BlankOrDefaultSort_PassesNormalizedDefaults( + string? sortBy, + string? sortDirection) + { + var data = new Mock(); + string? capturedSort = "sentinel"; + string? capturedDirection = "sentinel"; + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) => + { + capturedSort = s; + capturedDirection = d; + }) + .ReturnsAsync((new List(), 0)); + + await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy, sortDirection: sortDirection); + + capturedSort.Should().BeNull("a blank sort column must keep the legacy ordering"); + capturedDirection.Should().Be("asc"); + } + + [Theory] + [InlineData("rating")] + [InlineData("password; drop table locations")] + public async Task GetLocationListPagedAsync_InvalidSortBy_ThrowsValidationAndNeverQueries(string sortBy) + { + var data = new Mock(); + + var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => + e.PropertyName == "sortBy" + && e.ErrorMessage.Contains("code") + && e.ErrorMessage.Contains("poc")); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("ascending")] + [InlineData("DESC; drop table locations")] + public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection) + { + var data = new Mock(); + + var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => + e.PropertyName == "sortDirection" + && e.ErrorMessage.Contains("asc") + && e.ErrorMessage.Contains("desc")); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetLocationListPagedAsync_MapsClientAccountNameIntoRows() { using var ctx = NewContext(); - var existing = SeedLocation(ctx, "Gone", "Cedar Park"); + SeedAccount(ctx, 9, "Acme Co"); + SeedLocation(ctx, "Alpha Site", "Austin").AccountId = 9; + await ctx.SaveChangesAsync(); - var removed = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None); - var again = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None); + var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None); - removed.Should().BeTrue(); - again.Should().BeFalse(); - ctx.Locations.Should().BeEmpty(); + var row = page.Items.Should().ContainSingle().Subject; + row.AccountId.Should().Be(9); + row.AccountName.Should().Be("Acme Co"); } } diff --git a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs new file mode 100644 index 0000000..b366331 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs @@ -0,0 +1,607 @@ +using System.Security.Claims; +using System.Text.Json; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class LocationSiteContactsTests +{ + /// Fills the fields a new site must carry (client, address, one contact) unless the test set them. + private static LocationCreateRequestDTO WithSiteFields(ApplicationDbContext ctx, LocationCreateRequestDTO request) + { + if (request.AccountId == null) + { + if (!ctx.Accounts.Any(a => a.Id == 7)) + { + ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false }); + ctx.SaveChanges(); + } + + request.AccountId = 7; + } + + request.Address ??= "1 Depot Rd"; + request.City ??= "Dallas"; + request.State ??= "TX"; + request.Contacts ??= new List { new() { Name = "Main", Phone = "555-0100" } }; + return request; + } + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static LocationService NewService(ApplicationDbContext ctx) => + new( + new LocationDataService(ctx), + new AccountDataService(ctx), + new CreateLocationValidation(), + new UpdateLocationValidation(), + Mock.Of(), + new SeaHaven.Services.Implementation.TeamPermissionPolicy()); + + private static ClaimsPrincipal OrgWideAdmin() + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, "admin-1"), + new(ClaimTypes.Role, "Admin"), + new(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll) + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + + private static async Task SeedLocationWithContactsAsync(ApplicationDbContext ctx) + { + var service = NewService(ctx); + await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO + { + Name = "Depot", + Phone = "555-9000", + AccountId = null, + Contacts = new List + { + new() { Name = " Alice Cooper ", Phone = " 555-0100 " }, + new() { Name = "Bob Dillon", Phone = "555-0200"} + } + }), OrgWideAdmin(), CancellationToken.None); + return ctx.Locations.Include(l => l.Contacts).Single(); + } + + [Fact] + public async Task Create_WithContacts_PersistsTrimmedOrderedRows_KeepsSitePhoneIndependent_SetsAccountFromLocation() + { + using var ctx = NewContext(); + ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false }); + await ctx.SaveChangesAsync(); + + await NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO + { + Name = "Warehouse", + AccountId = 7, + Contacts = new List + { + new() { Name = " Alice Cooper ", Phone = " 555-0100 " }, + new() { Name = "Bob Dillon", Phone = "555-0200" } + } + }), OrgWideAdmin(), CancellationToken.None); + + var location = ctx.Locations.Include(l => l.Contacts).Single(); + location.PhoneNumber.Should().BeNull("Site Phone is independent of the contacts"); + + var contacts = location.Contacts.OrderBy(c => c.SiteContactOrder).ToList(); + contacts.Should().HaveCount(2); + contacts[0].FirstName.Should().Be("Alice Cooper"); + contacts[0].MiddleName.Should().BeNull(); + contacts[0].LastName.Should().BeNull(); + contacts[0].PhoneNumber.Should().Be("555-0100"); + contacts[0].SiteContactOrder.Should().Be(0); + contacts[0].AccountId.Should().Be(7, "AccountId must come from the location"); + contacts[0].LocationId.Should().Be(location.Id); + contacts[0].createdby.Should().Be("admin-1"); + contacts[0].CreatedDate.Should().NotBeNull(); + contacts[1].SiteContactOrder.Should().Be(1); + } + + [Fact] + public async Task Create_EmptyContactsArray_IsExplicitValidationError_AndWritesNothing() + { + using var ctx = NewContext(); + + var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO + { + Name = "Warehouse", + Contacts = new List() + }), OrgWideAdmin(), CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts"); + ctx.Locations.Should().BeEmpty(); + ctx.Contacts.Should().BeEmpty(); + } + + [Theory] + [InlineData(null, "555-0100", "Contact name is required.")] + [InlineData(" ", "555-0100", "Contact name is required.")] + [InlineData("Alice", null, "Contact phone is required.")] + [InlineData("Alice", " ", "Contact phone is required.")] + [InlineData("Alice", "555-0100", null)] + public async Task Create_ValidatesEachRow(string? name, string? phone, string? expectedError) + { + using var ctx = NewContext(); + var contacts = new List { new() { Name = name, Phone = phone } }; + + var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO + { + Name = "Warehouse", + Contacts = contacts + }), OrgWideAdmin(), CancellationToken.None); + + if (expectedError == null) + { + await act.Should().NotThrowAsync(); + return; + } + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(e => e.ErrorMessage == expectedError); + } + + [Fact] + public async Task Create_RejectsOversizedNameAndPhone() + { + using var ctx = NewContext(); + + var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO + { + Name = "Warehouse", + Contacts = new List + { + new() { Name = new string('x', 101), Phone = new string('5', 21) } + } + }), OrgWideAdmin(), CancellationToken.None); + + var thrown = (await act.Should().ThrowAsync()).Which; + thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 100")); + thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 20")); + } + + [Fact] + public async Task Create_RejectsMoreThanTwentyContacts() + { + using var ctx = NewContext(); + var contacts = Enumerable.Range(1, 21) + .Select(i => new SiteContactRequestDTO { Name = $"C{i}", Phone = "555-0100" }) + .ToList(); + + var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO + { + Name = "Warehouse", + Contacts = contacts + }), OrgWideAdmin(), CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("cannot exceed 20")); + } + + [Fact] + public async Task Update_ContactsNull_KeepsLegacyBehavior_AndDoesNotMutateContactRows() + { + using var ctx = NewContext(); + var seeded = await SeedLocationWithContactsAsync(ctx); + var before = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList(); + + await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO + { + Name = "Depot", + Phone = "555-9999" + }, OrgWideAdmin(), CancellationToken.None); + + var after = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList(); + after.Should().HaveCount(before.Count); + after[0].FirstName.Should().Be(before[0].FirstName); + after[0].PhoneNumber.Should().Be(before[0].PhoneNumber); + after[0].SiteContactOrder.Should().Be(before[0].SiteContactOrder); + after[0].IsDeleted.Should().Be(before[0].IsDeleted); + ctx.Locations.Single().PhoneNumber.Should().Be("555-9999", "legacy updates keep request.Phone"); + } + + [Fact] + public async Task Update_EmptyContactsArray_IsExplicitValidationError() + { + using var ctx = NewContext(); + var seeded = await SeedLocationWithContactsAsync(ctx); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO + { + Name = "Depot", + Contacts = new List() + }, OrgWideAdmin(), CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts"); + ctx.Contacts.Should().OnlyContain(c => c.IsDeleted != true); + } + + [Fact] + public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_LeavesSitePhoneToTheRequest() + { + using var ctx = NewContext(); + var seeded = await SeedLocationWithContactsAsync(ctx); + var alice = seeded.Contacts.Single(c => c.FirstName == "Alice Cooper"); + var bob = seeded.Contacts.Single(c => c.FirstName == "Bob Dillon"); + + await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO + { + Name = "Depot", + Contacts = new List + { + new() { Id = bob.Id, Name = "Bob Dillon", Phone = "555-0200" }, + new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0300" }, + new() { Name = "Cara Lane", Phone = "555-0400" } + } + }, OrgWideAdmin(), CancellationToken.None); + + var location = ctx.Locations.Include(l => l.Contacts).Single(); + location.PhoneNumber.Should().BeNull("Site Phone comes from the request, not the first contact"); + + var active = location.Contacts.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder).ToList(); + active.Should().HaveCount(3); + active.Select(c => c.FirstName).Should().ContainInOrder("Bob Dillon", "Alice Cooper", "Cara Lane"); + active.Select(c => c.SiteContactOrder).Should().ContainInOrder(new int?[] { 0, 1, 2 }, "order is densified from zero"); + active[1].PhoneNumber.Should().Be("555-0300"); + active[0].Id.Should().Be(bob.Id); + active[1].Id.Should().Be(alice.Id, "existing ids are preserved"); + active[2].AccountId.Should().Be(location.AccountId); + } + + [Fact] + public async Task Update_SoftDeletesRemovedContacts_WithAuditFields_KeepingRowsReadable() + { + using var ctx = NewContext(); + var seeded = await SeedLocationWithContactsAsync(ctx); + var alice = seeded.Contacts.Single(c => c.FirstName == "Alice Cooper"); + var bob = seeded.Contacts.Single(c => c.FirstName == "Bob Dillon"); + + await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO + { + Name = "Depot", + Contacts = new List { new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" } } + }, OrgWideAdmin(), CancellationToken.None); + + var removed = ctx.Contacts.AsNoTracking().Single(c => c.Id == bob.Id); + removed.IsDeleted.Should().BeTrue(); + removed.DeleterUserId.Should().Be("admin-1"); + removed.DeletionTime.Should().NotBeNull(); + removed.LastModificationTime.Should().NotBeNull(); + removed.FirstName.Should().Be("Bob Dillon", "soft deleted rows keep data so historical WorkOrderContacts still render"); + removed.PhoneNumber.Should().Be("555-0200"); + + var kept = ctx.Contacts.AsNoTracking().Single(c => c.Id == alice.Id); + kept.IsDeleted.Should().NotBeTrue(); + } + + [Fact] + public async Task Update_ForeignLocationContactId_RejectedWithoutMutation() + { + using var ctx = NewContext(); + var seeded = await SeedLocationWithContactsAsync(ctx); + ctx.Locations.Add(new Locations { Id = seeded.Id + 1, Name = "Other" }); + ctx.Contacts.Add(new Contacts { Id = 901, LocationId = seeded.Id + 1, FirstName = "Foreign", PhoneNumber = "555-0900" }); + await ctx.SaveChangesAsync(); + var snapshot = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList(); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO + { + Name = "Depot", + Contacts = new List + { + new() { Name = "Alice Cooper", Phone = "555-0100" }, + new() { Id = 901, Name = "Foreign", Phone = "555-0900" } + } + }, OrgWideAdmin(), CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => e.PropertyName.Contains("Id")); + + var after = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList(); + after.Should().HaveCount(snapshot.Count); + after.Should().BeEquivalentTo(snapshot, o => o.Excluding(c => c.Location)); + ctx.Locations.AsNoTracking().Single(l => l.Id == seeded.Id).PhoneNumber + .Should().Be("555-9000", "the rejected update must not persist any change"); + } + + [Fact] + public async Task Update_DeletedContactId_Rejected() + { + using var ctx = NewContext(); + var seeded = await SeedLocationWithContactsAsync(ctx); + var bob = seeded.Contacts.Single(c => c.FirstName == "Bob Dillon"); + bob.IsDeleted = true; + await ctx.SaveChangesAsync(); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO + { + Name = "Depot", + Contacts = new List { new() { Id = bob.Id, Name = "Bob Dillon", Phone = "555-0200" } } + }, OrgWideAdmin(), CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => e.PropertyName.Contains("Id")); + } + + [Fact] + public async Task Update_AccountLevelContactId_Rejected() + { + using var ctx = NewContext(); + var seeded = await SeedLocationWithContactsAsync(ctx); + ctx.Contacts.Add(new Contacts { Id = 902, AccountId = 55, LocationId = null, FirstName = "AccountOnly", PhoneNumber = "555-0900" }); + await ctx.SaveChangesAsync(); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO + { + Name = "Depot", + Contacts = new List { new() { Id = 902, Name = "AccountOnly", Phone = "555-0900" } } + }, OrgWideAdmin(), CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => e.PropertyName.Contains("Id")); + } + + [Fact] + public async Task Update_DuplicateContactIds_Rejected() + { + using var ctx = NewContext(); + var seeded = await SeedLocationWithContactsAsync(ctx); + var alice = seeded.Contacts.Single(c => c.FirstName == "Alice Cooper"); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO + { + Name = "Depot", + Contacts = new List + { + new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" }, + new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" } + } + }, OrgWideAdmin(), CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("more than once")); + } + + [Fact] + public async Task Update_AccountScopedCaller_CannotAttachContactsToForeignLocation() + { + using var ctx = NewContext(); + var seeded = await SeedLocationWithContactsAsync(ctx); + seeded.AccountId = 99; + await ctx.SaveChangesAsync(); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, "actor-1"), + new(ClaimTypes.Role, "Dispatcher"), + new(SeaHavenClaimTypes.AccountId, "4") + }; + var caller = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO + { + Name = "Depot", + Contacts = new List { new() { Name = "Nope", Phone = "555-0000" } } + }, caller, CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Contacts.Should().OnlyContain(c => c.IsDeleted != true); + } + + [Fact] + public async Task Detail_ExcludesDeletedContacts_AndOrdersBySiteContactOrderThenId() + { + using var ctx = NewContext(); + var seeded = await SeedLocationWithContactsAsync(ctx); + var alice = seeded.Contacts.Single(c => c.FirstName == "Alice Cooper"); + var bob = seeded.Contacts.Single(c => c.FirstName == "Bob Dillon"); + + await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO + { + Name = "Depot", + Contacts = new List { new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" } } + }, OrgWideAdmin(), CancellationToken.None); + + var detail = await NewService(ctx).GetLocationDetailAsync(seeded.Id, CancellationToken.None); + + detail!.Contacts.Should().ContainSingle().Which.Id.Should().Be(alice.Id); + } + + [Fact] + public async Task Detail_OrdersContactsByOrderThenId_WhenOrdersAreMissing() + { + using var ctx = NewContext(); + var loc = new Locations { Id = 5, Name = "Legacy", AccountId = 7 }; + ctx.Locations.Add(loc); + ctx.Contacts.AddRange( + new Contacts { Id = 31, LocationId = 5, AccountId = 7, FirstName = "No Order B", PhoneNumber = "555-2" }, + new Contacts { Id = 30, LocationId = 5, AccountId = 7, FirstName = "No Order A", PhoneNumber = "555-1" }); + await ctx.SaveChangesAsync(); + + var detail = await NewService(ctx).GetLocationDetailAsync(5, CancellationToken.None); + + detail!.Contacts!.Select(c => c.Name).Should().ContainInOrder("No Order A", "No Order B"); + } + + [Fact] + public async Task List_LoadsPageContactsInSingleBatchedRead() + { + var data = new Mock(); + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((new List + { + new() { Id = 1, Name = "One" }, + new() { Id = 2, Name = "Two" } + }, 2)); + data.Setup(d => d.GetSiteContactsByLocationIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = 10, LocationId = 1, SiteContactOrder = 1, FirstName = "Second", PhoneNumber = "555-2" }, + new() { Id = 11, LocationId = 1, SiteContactOrder = 0, FirstName = "First", PhoneNumber = "555-1" }, + new() { Id = 12, LocationId = 2, SiteContactOrder = 0, FirstName = "Solo", PhoneNumber = "555-3" } + }); + + var service = new LocationService( + data.Object, + Mock.Of(), + new CreateLocationValidation(), + new UpdateLocationValidation(), + Mock.Of(), + new SeaHaven.Services.Implementation.TeamPermissionPolicy()); + + var page = await service.GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None); + + page.Items.Should().HaveCount(2); + page.Items.Single(i => i.Id == 1).Contacts!.Select(c => c.Name) + .Should().ContainInOrder(new[] { "First", "Second" }, "contacts are ordered per location"); + page.Items.Single(i => i.Id == 2).Contacts!.Select(c => c.Name).Should().ContainSingle("Solo"); + data.Verify(d => d.GetSiteContactsByLocationIdsAsync(It.IsAny>(), It.IsAny()), Times.Once); + data.Verify(d => d.GetSiteContactsByLocationIdsAsync(It.Is>(ids => ids.Contains(1) && ids.Contains(2)), It.IsAny()), Times.Once); + } + + [Fact] + public async Task DataService_GetSiteContactsByLocationIdsAsync_ExcludesDeleted_AndOrders() + { + using var ctx = NewContext(); + ctx.Contacts.AddRange( + new Contacts { Id = 1, LocationId = 5, SiteContactOrder = 1, FirstName = "B" }, + new Contacts { Id = 2, LocationId = 5, SiteContactOrder = 0, FirstName = "A" }, + new Contacts { Id = 3, LocationId = 5, SiteContactOrder = 0, FirstName = "Tie", IsDeleted = true }, + new Contacts { Id = 4, LocationId = 6, SiteContactOrder = 0, FirstName = "Other" }); + await ctx.SaveChangesAsync(); + + var rows = await new LocationDataService(ctx).GetSiteContactsByLocationIdsAsync(new[] { 5 }, CancellationToken.None); + + rows.Select(r => r.FirstName).Should().ContainInOrder("A", "B"); + rows.Should().OnlyContain(r => r.IsDeleted != true); + } + + [Fact] + public async Task DataService_GetSiteContactsByLocationIdsAsync_EmptyInput_ReturnsWithoutQuerying() + { + using var ctx = NewContext(); + ctx.Contacts.Add(new Contacts { Id = 1, LocationId = 5, FirstName = "A" }); + await ctx.SaveChangesAsync(); + + var rows = await new LocationDataService(ctx).GetSiteContactsByLocationIdsAsync(Array.Empty(), CancellationToken.None); + + rows.Should().BeEmpty(); + } + + private sealed class ExposedSH138Migration : Data.SeaHavenIndustries.Migrations.SH138_SiteContacts + { + public void UpExposed(MigrationBuilder builder) => Up(builder); + public void DownExposed(MigrationBuilder builder) => Down(builder); + } + + [Fact] + public void Migration_SH138_IsAdditiveOnly() + { + var migration = new ExposedSH138Migration(); + + var up = new MigrationBuilder("SqlServer"); + migration.UpExposed(up); + + up.Operations.Should().ContainSingle("the migration must only add the SiteContactOrder column"); + var column = up.Operations.Single().Should().BeOfType().Subject; + column.Table.Should().Be("Contacts"); + column.Name.Should().Be("SiteContactOrder"); + column.IsNullable.Should().BeTrue("existing rows and legacy contacts have no order"); + column.ClrType.Should().Be(typeof(int)); + + var down = new MigrationBuilder("SqlServer"); + migration.DownExposed(down); + + down.Operations.Should().ContainSingle("reverting drops only the added column"); + down.Operations.Single().Should().BeOfType(); + } + + [Fact] + public void Migration_SH138_HasNoReorderIndexOrFkChange() + { + var migration = new ExposedSH138Migration(); + var up = new MigrationBuilder("SqlServer"); + migration.UpExposed(up); + + up.Operations.Should().NotContain(o => o is CreateIndexOperation, "no unique reorder index"); + up.Operations.Should().NotContain(o => o is AddForeignKeyOperation || o is DropForeignKeyOperation, "the existing restrict relationship is unchanged"); + up.Operations.Should().NotContain(o => o is DropColumnOperation || o is DropTableOperation || o is SqlOperation, "no destructive or backfill operations"); + } + + [Fact] + public async Task Controller_Detail_ProjectsContactsAndCompatibilityFields() + { + using var ctx = NewContext(); + var seeded = await SeedLocationWithContactsAsync(ctx); + var controller = new LocationController( + NewService(ctx), + Mock.Of>()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var result = await controller.GetLocationById(seeded.Id, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + using var json = JsonSerializer.SerializeToDocument(ok.Value); + var root = json.RootElement; + + root.GetProperty("Phone").GetString().Should().Be("555-9000", "Phone is the Site Phone, independent of contacts"); + root.GetProperty("Contact").GetString().Should().Be("Alice Cooper", "Contact is the first contact display name"); + var contacts = root.GetProperty("Contacts"); + contacts.GetArrayLength().Should().Be(2); + contacts[0].GetProperty("Name").GetString().Should().Be("Alice Cooper"); + contacts[0].GetProperty("Phone").GetString().Should().Be("555-0100"); + contacts[1].GetProperty("Name").GetString().Should().Be("Bob Dillon"); + } + + [Fact] + public async Task Controller_List_ProjectsFirstContactAsLegacyContactField() + { + using var ctx = NewContext(); + var seeded = await SeedLocationWithContactsAsync(ctx); + var controller = new LocationController( + NewService(ctx), + Mock.Of>()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var result = await controller.GetLocationList(cancellationToken: CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + using var json = JsonSerializer.SerializeToDocument(ok.Value); + var row = json.RootElement.GetProperty("Data").EnumerateArray().Single(); + + row.GetProperty("Contact").GetString().Should().Be("Alice Cooper"); + row.GetProperty("Phone").GetString().Should().Be("555-9000"); + row.GetProperty("Contacts").GetArrayLength().Should().Be(2); + } +} diff --git a/Api.SeaHavenIndustries.Tests/NotificationActivityFeedTests.cs b/Api.SeaHavenIndustries.Tests/NotificationActivityFeedTests.cs new file mode 100644 index 0000000..e81c349 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/NotificationActivityFeedTests.cs @@ -0,0 +1,452 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// Items addressed to the signed-in user: new assignments (SH-288), comments (SH-289), +/// mentions, and decisions on their uplift requests (SH-215). +/// +public class NotificationActivityFeedTests +{ + private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc); + private const string Me = "dispatcher-me"; + private const string Other = "dispatcher-other"; + private const string Admin = "admin-1"; + + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(Now); + } + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.Users.AddRange( + new ApplicationUser { Id = Me, FirstName = "Maya", LastName = "Reed" }, + new ApplicationUser { Id = Other, FirstName = "Omar", LastName = "Diaz" }, + new ApplicationUser { Id = Admin, FirstName = "Ada", LastName = "Stone" }); + context.SaveChanges(); + return context; + } + + private static NotificationFeedService NewService(ApplicationDbContext context) + => new( + new NotificationFeedDataService(context), + new VendorOperationsDataService(context, new DispatchDataService(context)), + new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context)), + new FixedTimeProvider()); + + private static ClaimsPrincipal User(int accountId, string role, string userId) + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) + }, "test")); + + private static ClaimsPrincipal Dispatcher(int accountId = 1, string userId = Me) => User(accountId, "Dispatcher", userId); + + private static WorkOrder AddWorkOrder(ApplicationDbContext context, int accountId, string? assignTo, string number, + LifecycleStatus status = LifecycleStatus.Pending) + { + var workOrder = new WorkOrder + { + AccountId = accountId, + AssignTo = assignTo, + InternalWONumber = number, + LifecycleStatus = status, + CreatedDate = Now.AddDays(-30) + }; + context.workOrders.Add(workOrder); + context.SaveChanges(); + return workOrder; + } + + private static void Assigned(ApplicationDbContext context, WorkOrder workOrder, string? by, DateTime at, string field = "AssignTo") + { + context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = workOrder.Id, + UserId = by, + FieldName = field, + OldValue = "", + NewValue = workOrder.AssignTo, + Action = "AssignmentChanged", + CreatedAt = at + }); + context.SaveChanges(); + } + + private static Comments Comment(ApplicationDbContext context, WorkOrder workOrder, string? by, string text, DateTime at, + string? commenter = null) + { + var comment = new Comments + { + WorkerOrderId = workOrder.Id, + UserId = by, + Commenter = commenter, + Commenttext = text, + CommentType = "General", + RecordType = "WorkOrder", + CreatedDate = at + }; + context.Comments.Add(comment); + context.SaveChanges(); + return comment; + } + + private static DispatchUpliftRequest Uplift(ApplicationDbContext context, WorkOrder workOrder, string requestedBy, + string status, decimal amount, string? decidedBy, DateTime? decidedAt, string? note = null) + { + var dispatch = new Dispatch { WorkOrderId = workOrder.Id, Status = "Sent" }; + context.Dispatches.Add(dispatch); + context.SaveChanges(); + var uplift = new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + RequestedNTE = amount, + Status = status, + createdby = requestedBy, + DecidedAt = decidedAt, + DecidedByUserId = decidedBy, + DecisionNote = note, + CreatedDate = Now.AddDays(-2) + }; + context.DispatchUpliftRequests.Add(uplift); + context.SaveChanges(); + return uplift; + } + + private static IReadOnlyList Items(NotificationFeedDto feed, string reason) + => feed.Sections.SingleOrDefault(section => section.Reason == reason)?.Items + ?? Array.Empty(); + + // ---- SH-288 new assignments ---- + + [Fact] + public async Task Assignment_BySomeoneElse_IsOneLowActivityItemThatOpensTheWorkOrder() + { + using var context = NewContext(); + var workOrder = AddWorkOrder(context, 1, Me, "WO-100"); + Assigned(context, workOrder, Other, Now.AddHours(-3)); + + var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); + + var item = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject; + item.Id.Should().Be($"new-assigned-{workOrder.Id}"); + item.Severity.Should().Be(NotificationSeverities.Low); + item.RowType.Should().Be(NotificationRowTypes.Dismissable); + item.Title.Should().Be("WO #WO-100 was assigned to you"); + item.Count.Should().Be(1); + item.TriggeredAt.Should().Be(Now.AddHours(-3)); + item.TriggeredAt.Kind.Should().Be(DateTimeKind.Utc); + item.Target.Kind.Should().Be(NotificationTargetKinds.WorkOrder); + item.Target.WorkOrderId.Should().Be(workOrder.Id); + item.Target.Tab.Should().Be(NotificationWorkOrderTabs.Info); + feed.Sections.Single(s => s.Reason == NotificationReasons.Activity).Label.Should().Be("Recent activity"); + } + + [Fact] + public async Task Assignments_MadeInOneAction_BecomeOneGroupedItemThatOpensTheUsersQueue() + { + using var context = NewContext(); + var first = AddWorkOrder(context, 1, Me, "WO-1"); + var second = AddWorkOrder(context, 1, Me, "WO-2"); + var third = AddWorkOrder(context, 1, Me, "WO-3"); + var later = AddWorkOrder(context, 1, Me, "WO-4"); + Assigned(context, first, Other, Now.AddHours(-5)); + Assigned(context, second, Other, Now.AddHours(-5).AddSeconds(20)); + Assigned(context, third, Other, Now.AddHours(-5).AddSeconds(50)); + // Same person, an hour later: a separate action. + Assigned(context, later, Other, Now.AddHours(-4)); + + var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); + + var items = Items(feed, NotificationReasons.Activity); + items.Should().HaveCount(2); + var grouped = items.Single(i => i.Count == 3); + grouped.Id.Should().Be($"new-assigned-group-{first.Id}"); + grouped.Title.Should().Be("3 new work orders assigned to you"); + grouped.Severity.Should().Be(NotificationSeverities.Low); + grouped.Target.Kind.Should().Be(NotificationTargetKinds.Queue); + grouped.Target.Queue.Should().Be(NotificationQueues.AssignedToMe); + grouped.WorkOrders.Select(w => w.Id).Should().BeEquivalentTo(new[] { first.Id, second.Id, third.Id }); + items.Single(i => i.Count == 1).Target.WorkOrderId.Should().Be(later.Id); + } + + [Fact] + public async Task Assignment_IsNotShownWhenSelfMadeStaleClosedOrReassignedAway() + { + using var context = NewContext(); + Assigned(context, AddWorkOrder(context, 1, Me, "SELF"), Me, Now.AddHours(-1)); + Assigned(context, AddWorkOrder(context, 1, Me, "STALE"), Other, Now.AddDays(-8)); + Assigned(context, AddWorkOrder(context, 1, Me, "DONE", LifecycleStatus.Completed), Other, Now.AddHours(-1)); + var movedAway = AddWorkOrder(context, 1, Me, "MOVED"); + Assigned(context, movedAway, Other, Now.AddHours(-2)); + movedAway.AssignTo = Other; + Assigned(context, movedAway, Admin, Now.AddHours(-1)); + // Reassigned to me earlier, then someone re-saved it to me again recently: the latest entry counts. + var reassigned = AddWorkOrder(context, 1, Me, "LATEST"); + Assigned(context, reassigned, Other, Now.AddDays(-9)); + Assigned(context, reassigned, Admin, Now.AddMinutes(-10)); + + var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); + + Items(feed, NotificationReasons.Activity).Should().ContainSingle() + .Which.Target.WorkOrderId.Should().Be(reassigned.Id); + } + + [Fact] + public async Task Assignment_CountsLegacyAuditEntriesButNotUnauditedAssignees() + { + using var context = NewContext(); + var legacy = AddWorkOrder(context, 1, Me, "LEGACY"); + Assigned(context, legacy, Other, Now.AddDays(-1), field: "AssignedTo"); + // Assigned at some point with no audit trail: there is no assignment event to report. + var unaudited = AddWorkOrder(context, 1, Me, "UNAUDITED"); + unaudited.CreatedDate = Now.AddHours(-2); + context.SaveChanges(); + + var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); + + Items(feed, NotificationReasons.Activity).Should().ContainSingle() + .Which.Target.WorkOrderId.Should().Be(legacy.Id); + } + + // ---- SH-289 comments and mentions ---- + + [Fact] + public async Task Comments_BySomeoneElseOnMyWorkOrder_GroupIntoOneMediumItemOnTheCommentsTab() + { + using var context = NewContext(); + var workOrder = AddWorkOrder(context, 1, Me, "WO-7"); + var first = Comment(context, workOrder, Other, "Vendor is on site", Now.AddHours(-3)); + var second = Comment(context, workOrder, null, "Reply from the email thread", Now.AddHours(-2), commenter: "Site Lead"); + + var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); + + var item = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject; + item.Id.Should().Be($"comments-{workOrder.Id}-{second.Id}"); + item.Title.Should().Be("2 new comments on WO #WO-7"); + item.Severity.Should().Be(NotificationSeverities.Medium); + item.Count.Should().Be(1); + // The group starts at its earliest comment; the Comments tab highlights from there. + item.TriggeredAt.Should().Be(first.CreatedDate!.Value); + item.TriggeredAt.Kind.Should().Be(DateTimeKind.Utc); + item.Target.WorkOrderId.Should().Be(workOrder.Id); + item.Target.Tab.Should().Be(NotificationWorkOrderTabs.Comments); + } + + [Fact] + public async Task Comment_Single_ShowsTheCommenterAndAOneLinePreview() + { + using var context = NewContext(); + var workOrder = AddWorkOrder(context, 1, Me, "WO-8"); + var text = "Please ask @[" + Admin + ":Ada Stone] about the gate code\nbefore the vendor arrives, because the " + + "site closes early on Fridays and security will not let anyone in after four"; + Comment(context, workOrder, Other, text, Now.AddHours(-1)); + + var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); + + var title = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject.Title; + title.Should().StartWith("Omar Diaz commented on WO #WO-8: “Please ask @Ada Stone about the gate code before"); + title.Should().EndWith("…”"); + NotificationActivityItems.Preview(text).Should().HaveLength(80); + } + + [Fact] + public async Task Comments_NeverNotifyTheirAuthorAndClearOnceTheUserReplies() + { + using var context = NewContext(); + var workOrder = AddWorkOrder(context, 1, Me, "WO-9"); + Comment(context, workOrder, Me, "My own note", Now.AddHours(-5)); + Comment(context, workOrder, Other, "Answered already", Now.AddHours(-4)); + Comment(context, workOrder, Me, "Thanks", Now.AddHours(-3)); + + var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); + Items(feed, NotificationReasons.Activity).Should().BeEmpty(); + + var unanswered = Comment(context, workOrder, Other, "One more thing", Now.AddHours(-1)); + feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); + var item = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject; + item.Title.Should().Be("Omar Diaz commented on WO #WO-9: “One more thing”"); + item.TriggeredAt.Should().Be(unanswered.CreatedDate!.Value); + } + + [Fact] + public async Task Comments_ReachPriorCommentersButNotBystanders() + { + using var context = NewContext(); + // Someone else's work order that I commented on earlier this month. + var joined = AddWorkOrder(context, 1, Other, "JOINED"); + Comment(context, joined, Me, "Adding context", Now.AddDays(-20)); + Comment(context, joined, Other, "Follow-up", Now.AddHours(-2)); + var bystander = AddWorkOrder(context, 1, Other, "NOT-MINE"); + Comment(context, bystander, Admin, "Internal note", Now.AddHours(-2)); + var stale = AddWorkOrder(context, 1, Me, "OLD"); + Comment(context, stale, Other, "Last week", Now.AddDays(-8)); + + var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); + + Items(feed, NotificationReasons.Activity).Should().ContainSingle() + .Which.Target.WorkOrderId.Should().Be(joined.Id); + // The owner of NOT-MINE is notified of the admin's note, and not of their own follow-up. + var ownerFeed = await NewService(context).GetFeedAsync(Dispatcher(userId: Other), CancellationToken.None); + Items(ownerFeed, NotificationReasons.Activity).Should().ContainSingle() + .Which.Target.WorkOrderId.Should().Be(bystander.Id); + } + + [Fact] + public async Task Mention_IsAHighMentionsItemAndIsNotAlsoCountedAsAComment() + { + using var context = NewContext(); + var workOrder = AddWorkOrder(context, 1, Me, "WO-11"); + Comment(context, workOrder, Other, "Plain update", Now.AddHours(-3)); + var mention = Comment(context, workOrder, Other, "@[" + Me + ":Maya Reed] can you confirm?", Now.AddHours(-2)); + // A mention reaches the user even on a work order they are not otherwise part of. + var elsewhere = AddWorkOrder(context, 1, Other, "WO-12"); + Comment(context, elsewhere, Admin, "Looping in @[" + Me + ":Maya Reed]", Now.AddHours(-1)); + + var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); + + var mentions = Items(feed, NotificationReasons.Mentions); + mentions.Should().HaveCount(2); + var onMine = mentions.Single(i => i.Target.WorkOrderId == workOrder.Id); + onMine.Id.Should().Be($"mention-{mention.Id}"); + onMine.Title.Should().Be("Omar Diaz mentioned you on WO #WO-11"); + onMine.Severity.Should().Be(NotificationSeverities.High); + onMine.Target.Tab.Should().Be(NotificationWorkOrderTabs.Comments); + onMine.TriggeredAt.Should().Be(mention.CreatedDate!.Value); + mentions.Single(i => i.Target.WorkOrderId == elsewhere.Id).Title.Should().Be("Ada Stone mentioned you on WO #WO-12"); + + var activity = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject; + activity.Title.Should().Be("Omar Diaz commented on WO #WO-11: “Plain update”"); + feed.Sections.Select(s => s.Reason).Should().ContainInOrder(NotificationReasons.Mentions, NotificationReasons.Activity); + } + + [Fact] + public void MentionsUser_MatchesOnlyTheUsersOwnToken() + { + NotificationActivityItems.MentionsUser("hi @[" + Me + ":Maya Reed]", Me).Should().BeTrue(); + NotificationActivityItems.MentionsUser("hi @[" + Me + "x:Someone Else]", Me).Should().BeFalse(); + NotificationActivityItems.MentionsUser("hi @Maya Reed", Me).Should().BeFalse(); + NotificationActivityItems.MentionsUser(null, Me).Should().BeFalse(); + } + + // ---- SH-215 uplift decisions ---- + + [Fact] + public async Task UpliftDecisions_OnMyRequests_ShowAmountAndReasonAndOpenTheUplifts() + { + using var context = NewContext(); + var workOrder = AddWorkOrder(context, 1, Other, "WO-20"); + var approved = Uplift(context, workOrder, Me, "Approved", 1250m, Admin, Now.AddHours(-3)); + var rejected = Uplift(context, workOrder, Me, "Rejected", 400m, Admin, Now.AddHours(-2), "Quote does not match the scope agreed"); + var revoked = Uplift(context, workOrder, Me, "Revoked", 90.5m, Admin, Now.AddHours(-1), "Duplicate of an earlier uplift"); + var legacy = Uplift(context, workOrder, Me, "Denied", 75m, Admin, Now.AddHours(-4), "Old wording"); + + var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); + + var items = Items(feed, NotificationReasons.Activity); + items.Should().HaveCount(4); + var approvedItem = items.Single(i => i.Id == $"uplift-approved-{approved.Id}"); + approvedItem.Title.Should().Be("Your $1,250.00 uplift on WO #WO-20 was approved"); + approvedItem.Severity.Should().Be(NotificationSeverities.Medium); + approvedItem.Target.Tab.Should().Be(NotificationWorkOrderTabs.Uplifts); + approvedItem.Target.WorkOrderId.Should().Be(workOrder.Id); + var rejectedItem = items.Single(i => i.Id == $"uplift-rejected-{rejected.Id}"); + rejectedItem.Title.Should().Be("Your $400.00 uplift on WO #WO-20 was rejected: “Quote does not match the scope agreed”"); + rejectedItem.Severity.Should().Be(NotificationSeverities.High); + var revokedItem = items.Single(i => i.Id == $"uplift-revoked-{revoked.Id}"); + revokedItem.Title.Should().Be("Your $90.50 uplift on WO #WO-20 was revoked: “Duplicate of an earlier uplift”"); + revokedItem.Severity.Should().Be(NotificationSeverities.High); + revokedItem.TriggeredAt.Should().Be(Now.AddHours(-1)); + items.Single(i => i.Id == $"uplift-rejected-{legacy.Id}").Severity.Should().Be(NotificationSeverities.High); + // The requester need not be the dispatcher who owns the work order. + var ownerFeed = await NewService(context).GetFeedAsync(Dispatcher(userId: Other), CancellationToken.None); + Items(ownerFeed, NotificationReasons.Activity).Should().BeEmpty(); + } + + [Fact] + public async Task UpliftDecisions_SkipPendingAutoApprovedSelfRevokedAndStaleRequests() + { + using var context = NewContext(); + var workOrder = AddWorkOrder(context, 1, Me, "WO-21"); + Uplift(context, workOrder, Me, "Pending", 100m, null, null); + Uplift(context, workOrder, Me, "NoApprovalRequired", 100m, null, null); + Uplift(context, workOrder, Me, "Revoked", 100m, Me, Now.AddHours(-1)); + Uplift(context, workOrder, Me, "Approved", 100m, Admin, Now.AddDays(-8)); + var deleted = Uplift(context, workOrder, Me, "Approved", 100m, Admin, Now.AddHours(-1)); + deleted.IsDeleted = true; + context.SaveChanges(); + + var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); + + Items(feed, NotificationReasons.Activity).Should().BeEmpty(); + } + + // ---- scope ---- + + [Fact] + public async Task PersonalItems_NeverCrossAccounts() + { + using var context = NewContext(); + var foreign = AddWorkOrder(context, 2, Me, "FOREIGN"); + Assigned(context, foreign, Other, Now.AddHours(-1)); + Comment(context, foreign, Other, "@[" + Me + ":Maya Reed] hello", Now.AddHours(-1)); + Uplift(context, foreign, Me, "Approved", 10m, Admin, Now.AddHours(-1)); + + var sameUserOtherAccount = await NewService(context).GetFeedAsync(Dispatcher(accountId: 1), CancellationToken.None); + sameUserOtherAccount.Sections.Should().BeEmpty(); + + var inAccount = await NewService(context).GetFeedAsync(Dispatcher(accountId: 2), CancellationToken.None); + Items(inAccount, NotificationReasons.Mentions).Should().ContainSingle(); + Items(inAccount, NotificationReasons.Activity).Should().HaveCount(2); + } + + [Fact] + public async Task PersonalItems_ReachEveryFeedRoleByUserId() + { + using var context = NewContext(); + var workOrder = AddWorkOrder(context, 1, Other, "WO-30"); + Comment(context, workOrder, Other, "@[" + Admin + ":Ada Stone] please approve", Now.AddHours(-1)); + + var adminFeed = await NewService(context).GetFeedAsync(User(1, "Admin", Admin), CancellationToken.None); + Items(adminFeed, NotificationReasons.Mentions).Should().ContainSingle() + .Which.Title.Should().Be("Omar Diaz mentioned you on WO #WO-30"); + + var managerFeed = await NewService(context).GetFeedAsync(User(1, "Manager", "manager-1"), CancellationToken.None); + Items(managerFeed, NotificationReasons.Mentions).Should().BeEmpty(); + Items(managerFeed, NotificationReasons.Activity).Should().BeEmpty(); + } + + [Fact] + public async Task PersonalQueries_ForwardCancellation() + { + using var context = NewContext(); + var data = new NotificationFeedDataService(context); + var scope = new NotificationPersonalScope(1, Me); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + + await FluentActions.Awaiting(() => data.GetRecentAssignmentsAsync(scope, Now, 10, cancelled.Token)) + .Should().ThrowAsync(); + await FluentActions.Awaiting(() => data.GetUnansweredCommentsAsync(scope, Now, 10, cancelled.Token)) + .Should().ThrowAsync(); + await FluentActions.Awaiting(() => data.GetUpliftDecisionsAsync(scope, Now, 10, cancelled.Token)) + .Should().ThrowAsync(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs b/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs new file mode 100644 index 0000000..6f483f1 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs @@ -0,0 +1,590 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class NotificationFeedServiceTests +{ + // 12:00 in New York, so the business date is 2026-09-18. + private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Today = new(2026, 9, 18); + + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(Now); + } + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static NotificationFeedService NewService(ApplicationDbContext context) + { + var resolver = new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context)); + return new NotificationFeedService( + new NotificationFeedDataService(context), + new VendorOperationsDataService(context, new DispatchDataService(context)), + resolver, + new FixedTimeProvider()); + } + + private static ClaimsPrincipal User(int accountId, string role, string userId = "user-1") + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) + }, "test")); + + private static ClaimsPrincipal OrgWideAdmin() + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll), + new Claim(ClaimTypes.NameIdentifier, "admin-1"), + new Claim(ClaimTypes.Role, "Admin") + }, "test")); + + private static WorkOrder Open(int accountId, string? assignTo = null, DateTime? scheduled = null, string? number = null) + => new() + { + AccountId = accountId, + AssignTo = assignTo, + ScheduledDate = scheduled, + InternalWONumber = number, + LifecycleStatus = LifecycleStatus.Scheduled, + CreatedDate = Now.AddDays(-3) + }; + + private static WorkOrder AvetaDue(int accountId, string assignTo, DateTime scheduled, string number) + { + var workOrder = Open(accountId, assignTo, scheduled, number); + workOrder.AvetaRequired = true; + return workOrder; + } + + private static NotificationSectionDto Section(NotificationFeedDto feed, string reason) + => feed.Sections.Single(section => section.Reason == reason); + + private static void AddActiveDispatch(ApplicationDbContext context, WorkOrder workOrder) + { + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + context.Vendors.Add(vendor); + context.SaveChanges(); + context.Dispatches.Add(new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Sent", + ScheduledDate = workOrder.ScheduledDate + }); + context.SaveChanges(); + } + + [Fact] + public async Task Unassigned_GroupsEveryOpenUnassignedWorkOrderIntoOneHighItem() + { + using var context = NewContext(); + var newest = Open(1); + newest.CreatedDate = Now.AddHours(-1); + var completed = Open(1); + completed.LifecycleStatus = LifecycleStatus.Completed; + var canceled = Open(1); + canceled.LifecycleStatus = LifecycleStatus.Canceled; + var legacyDone = Open(1); + legacyDone.LifecycleStatus = null; + legacyDone.Status = "Done"; + var template = Open(1); + template.istemplate = true; + var deleted = Open(1); + deleted.IsDeleted = true; + context.workOrders.AddRange( + Open(1), Open(1, assignTo: ""), newest, Open(1, assignTo: "dispatcher-1"), + completed, canceled, legacyDone, template, deleted); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.Unassigned); + section.Label.Should().Be("Unassigned"); + section.Count.Should().Be(3); + var item = section.Items.Should().ContainSingle().Subject; + item.Id.Should().Be("unassigned"); + item.Severity.Should().Be(NotificationSeverities.High); + item.RowType.Should().Be(NotificationRowTypes.Dismissable); + item.Title.Should().Be("3 work orders are unassigned"); + item.Count.Should().Be(3); + item.TriggeredAt.Should().Be(newest.CreatedDate); + item.Target.Kind.Should().Be(NotificationTargetKinds.Queue); + item.Target.Queue.Should().Be(NotificationQueues.Unassigned); + } + + [Fact] + public async Task Unassigned_CountFollowsAssignmentAndSectionDisappearsAtZero() + { + using var context = NewContext(); + var first = Open(1); + var second = Open(1); + context.workOrders.AddRange(first, second); + await context.SaveChangesAsync(); + var service = NewService(context); + + first.AssignTo = "dispatcher-1"; + await context.SaveChangesAsync(); + var afterOne = await service.GetFeedAsync(User(1, "Admin"), CancellationToken.None); + var item = Section(afterOne, NotificationReasons.Unassigned).Items.Single(); + item.Title.Should().Be("1 work order is unassigned"); + item.Id.Should().Be("unassigned", "the id stays stable so a session dismissal survives count changes"); + + second.AssignTo = "dispatcher-2"; + await context.SaveChangesAsync(); + var afterAll = await service.GetFeedAsync(User(1, "Admin"), CancellationToken.None); + afterAll.Sections.Should().NotContain(section => section.Reason == NotificationReasons.Unassigned); + } + + [Theory] + [InlineData("Admin")] + [InlineData("Manager")] + [InlineData("Scheduler")] + public async Task Unassigned_IsShownToRolesThatSeeEveryDispatcher(string role) + { + using var context = NewContext(); + context.workOrders.Add(Open(1)); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, role), CancellationToken.None); + + Section(feed, NotificationReasons.Unassigned).Count.Should().Be(1); + } + + [Fact] + public async Task Unassigned_IsNotShownToDispatchers() + { + using var context = NewContext(); + context.workOrders.Add(Open(1)); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); + + feed.Sections.Should().BeEmpty(); + } + + [Fact] + public async Task AvetaMissing_OnlyFlagsOpenRequiredWorkOrdersInTheOneDayWindowWithoutTheDocument() + { + using var context = NewContext(); + var dueToday = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "A-TODAY"); + var dueTomorrow = AvetaDue(1, "dispatcher-1", Today.AddDays(1).AddHours(9), "A-TOMORROW"); + var withDocument = AvetaDue(1, "dispatcher-1", Today.AddHours(10), "A-HAS-DOC"); + var withDeletedDocument = AvetaDue(1, "dispatcher-1", Today.AddHours(11), "A-DELETED-DOC"); + var dayAfterTomorrow = AvetaDue(1, "dispatcher-1", Today.AddDays(2).AddHours(9), "A-LATER"); + var yesterday = AvetaDue(1, "dispatcher-1", Today.AddDays(-1).AddHours(9), "A-PAST"); + var completed = AvetaDue(1, "dispatcher-1", Today.AddHours(12), "A-DONE"); + completed.LifecycleStatus = LifecycleStatus.Completed; + var notRequired = Open(1, "dispatcher-1", Today.AddHours(13), "A-NOT-REQUIRED"); + context.workOrders.AddRange( + dueToday, dueTomorrow, withDocument, withDeletedDocument, dayAfterTomorrow, yesterday, completed, notRequired); + await context.SaveChangesAsync(); + context.workOrderAttachments.AddRange( + new WorkOrderAttachments { WorkorderId = withDocument.Id, Category = WorkOrderMediaCategory.Aveta }, + new WorkOrderAttachments { WorkorderId = withDeletedDocument.Id, Category = WorkOrderMediaCategory.Aveta, IsDeleted = true }, + new WorkOrderAttachments { WorkorderId = dueToday.Id, Category = WorkOrderMediaCategory.Extra }); + await context.SaveChangesAsync(); + AddActiveDispatch(context, dueToday); + AddActiveDispatch(context, dueTomorrow); + AddActiveDispatch(context, withDeletedDocument); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.AvetaMissing); + section.Label.Should().Be("Aveta Missing"); + section.Count.Should().Be(3); + section.Items.Select(item => item.WorkOrders.Single().Number) + .Should().Equal("A-TOMORROW", "A-TODAY", "A-DELETED-DOC"); + var tomorrowItem = section.Items[0]; + tomorrowItem.Id.Should().Be($"aveta-missing-{dueTomorrow.Id}"); + tomorrowItem.Severity.Should().Be(NotificationSeverities.Medium); + tomorrowItem.Title.Should().Be("WO #A-TOMORROW is missing the Aveta document"); + tomorrowItem.Target.Kind.Should().Be(NotificationTargetKinds.WorkOrder); + tomorrowItem.Target.WorkOrderId.Should().Be(dueTomorrow.Id); + tomorrowItem.Target.Tab.Should().Be(NotificationWorkOrderTabs.Extras); + } + + [Fact] + public async Task AvetaMissing_ClearsOnceTheDocumentIsAttached() + { + using var context = NewContext(); + var workOrder = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "A-1"); + context.workOrders.Add(workOrder); + await context.SaveChangesAsync(); + AddActiveDispatch(context, workOrder); + var service = NewService(context); + var dispatcher = User(1, "Dispatcher", "dispatcher-1"); + + (await service.GetFeedAsync(dispatcher, CancellationToken.None)).Sections + .Should().ContainSingle(section => section.Reason == NotificationReasons.AvetaMissing); + + context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = workOrder.Id, Category = WorkOrderMediaCategory.Aveta }); + await context.SaveChangesAsync(); + + (await service.GetFeedAsync(dispatcher, CancellationToken.None)).Sections.Should().BeEmpty(); + } + + [Fact] + public async Task NoVendor_MatchesTheVendorReminderRuleWithinFortyEightHours() + { + using var context = NewContext(); + var noVendor = Open(1, "dispatcher-1", Now.AddHours(24), "NV-1"); + var withDispatch = Open(1, "dispatcher-1", Now.AddHours(20), "NV-DISPATCHED"); + var withLinkedDispatch = Open(1, "dispatcher-1", Now.AddHours(22), "NV-LINKED"); + var withFinishedDispatch = Open(1, "dispatcher-1", Now.AddHours(30), "NV-FINISHED"); + var tooFar = Open(1, "dispatcher-1", Now.AddHours(72), "NV-LATER"); + context.workOrders.AddRange(noVendor, withDispatch, withLinkedDispatch, withFinishedDispatch, tooFar); + await context.SaveChangesAsync(); + AddActiveDispatch(context, withDispatch); + var vendor = new Vendor { CompanyName = "Linked", IsActive = true }; + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + var multi = new Dispatch { VendorId = vendor.Id, WorkOrderId = withDispatch.Id, Status = "Sent" }; + var finished = new Dispatch { VendorId = vendor.Id, WorkOrderId = withFinishedDispatch.Id, Status = "Completed" }; + context.Dispatches.AddRange(multi, finished); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = multi.Id, WorkOrderId = withLinkedDispatch.Id }); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.NoVendor); + section.Label.Should().Be("No Vendor"); + section.Items.Select(item => item.WorkOrders.Single().Number).Should().Equal("NV-FINISHED", "NV-1"); + var item = section.Items.Single(row => row.Id == $"no-vendor-{noVendor.Id}"); + item.Severity.Should().Be(NotificationSeverities.Medium); + item.Title.Should().Be("WO #NV-1 starts within 48h with no vendor assigned"); + item.TriggeredAt.Should().Be(noVendor.ScheduledDate!.Value.AddHours(-48)); + item.Target.WorkOrderId.Should().Be(noVendor.Id); + } + + [Fact] + public async Task NoVendor_FlagsTheSameWorkOrdersAsTheVendorOperationsReminders() + { + using var context = NewContext(); + var first = Open(1, "dispatcher-1", Now.AddHours(10), "P-1"); + var second = Open(2, "dispatcher-2", Now.AddHours(40), "P-2"); + var dispatched = Open(1, "dispatcher-1", Now.AddHours(12), "P-3"); + context.workOrders.AddRange(first, second, dispatched); + await context.SaveChangesAsync(); + AddActiveDispatch(context, dispatched); + var vendorService = new VendorOperationsService( + new VendorOperationsDataService(context, new DispatchDataService(context)), new FixedTimeProvider()); + + var reminders = await vendorService.GetNotificationsAsync(CancellationToken.None); + var feed = await NewService(context).GetFeedAsync(OrgWideAdmin(), CancellationToken.None); + + var reminderIds = reminders.Items.OfType().Select(item => item.Id); + Section(feed, NotificationReasons.NoVendor).Items.Select(item => item.Id) + .Should().BeEquivalentTo(reminderIds); + } + + [Fact] + public async Task NoVendor_TreatsSoftDeletedDispatchesAsNoVendor() + { + using var context = NewContext(); + var deletedDirect = Open(1, "dispatcher-1", Now.AddHours(20), "NV-DEL-DIRECT"); + var deletedLinked = Open(1, "dispatcher-1", Now.AddHours(22), "NV-DEL-LINKED"); + context.workOrders.AddRange(deletedDirect, deletedLinked); + await context.SaveChangesAsync(); + var vendor = new Vendor { CompanyName = "Removed", IsActive = true }; + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + var direct = new Dispatch { VendorId = vendor.Id, WorkOrderId = deletedDirect.Id, Status = "Sent", IsDeleted = true }; + var linked = new Dispatch { VendorId = vendor.Id, WorkOrderId = deletedDirect.Id, Status = "Sent", IsDeleted = true }; + context.Dispatches.AddRange(direct, linked); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = linked.Id, WorkOrderId = deletedLinked.Id }); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + Section(feed, NotificationReasons.NoVendor).Items + .Select(item => item.WorkOrders.Single().Number) + .Should().BeEquivalentTo("NV-DEL-DIRECT", "NV-DEL-LINKED"); + } + + [Fact] + public async Task Sections_OrderBySeverityThenFixedReasonOrder_AndItemsBySeverityThenRecency() + { + using var context = NewContext(); + var aveta = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "AV-1"); + var earlierNoVendor = Open(1, "dispatcher-1", Now.AddHours(5), "NV-EARLY"); + var laterNoVendor = Open(1, "dispatcher-1", Now.AddHours(30), "NV-LATE"); + context.workOrders.AddRange(aveta, earlierNoVendor, laterNoVendor, Open(1)); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None); + + feed.GeneratedAt.Should().Be(Now); + feed.Sections.Select(section => section.Reason).Should().Equal( + NotificationReasons.Unassigned, NotificationReasons.NoVendor, NotificationReasons.AvetaMissing); + feed.Sections.Select(section => section.Severity).Should().Equal( + NotificationSeverities.High, NotificationSeverities.Medium, NotificationSeverities.Medium); + var noVendorNumbers = Section(feed, NotificationReasons.NoVendor).Items + .Select(item => item.WorkOrders.Single().Number); + noVendorNumbers.Should().ContainInOrder("NV-LATE", "NV-EARLY"); + } + + [Fact] + public void Ordering_BreaksEqualSeverityTiesByReasonOrderAndPutsHigherSeverityFirst() + { + static NotificationItemDto Item(string id, string reason, string severity, int minutes) => new() + { + Id = id, + Reason = reason, + Severity = severity, + Count = 1, + TriggeredAt = Now.AddMinutes(minutes) + }; + + var sections = NotificationFeedOrdering.Order(new (string, int, IReadOnlyList)[] + { + (NotificationReasons.AvetaMissing, 1, new[] { Item("a", NotificationReasons.AvetaMissing, NotificationSeverities.Medium, 0) }), + (NotificationReasons.Activity, 2, new[] + { + Item("old-low", NotificationReasons.Activity, NotificationSeverities.Low, -10), + Item("new-low", NotificationReasons.Activity, NotificationSeverities.Low, 10), + Item("high", NotificationReasons.Activity, NotificationSeverities.High, -20) + }), + (NotificationReasons.Unassigned, 0, Array.Empty()), + (NotificationReasons.NoVendor, 1, new[] { Item("n", NotificationReasons.NoVendor, NotificationSeverities.Medium, 0) }) + }); + + sections.Select(section => section.Reason).Should().Equal( + NotificationReasons.Activity, NotificationReasons.NoVendor, NotificationReasons.AvetaMissing); + sections[0].Items.Select(item => item.Id).Should().Equal("high", "new-low", "old-low"); + sections[0].Label.Should().Be("Recent activity"); + } + + [Fact] + public async Task Dispatcher_OnlySeesTheirOwnWorkOrders() + { + using var context = NewContext(); + context.workOrders.AddRange( + AvetaDue(1, "dispatcher-1", Today.AddHours(9), "MINE"), + AvetaDue(1, "dispatcher-2", Today.AddHours(9), "THEIRS"), + Open(1, "dispatcher-2", Now.AddHours(5), "THEIRS-NV")); + await context.SaveChangesAsync(); + foreach (var workOrder in context.workOrders.Where(w => w.AvetaRequired).ToList()) + AddActiveDispatch(context, workOrder); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + feed.Sections.Select(section => section.Reason).Should().Equal(NotificationReasons.AvetaMissing); + Section(feed, NotificationReasons.AvetaMissing).Items.Single().WorkOrders.Single().Number.Should().Be("MINE"); + } + + [Fact] + public async Task CrossTenant_AccountUserNeverSeesAnotherAccountsWorkOrders() + { + using var context = NewContext(); + var otherAveta = AvetaDue(2, "dispatcher-1", Today.AddHours(9), "OTHER-AV"); + var otherNoVendor = Open(2, "dispatcher-1", Now.AddHours(5), "OTHER-NV"); + var otherUnassigned = Open(2); + var ownUnassigned = Open(1); + context.workOrders.AddRange(otherAveta, otherNoVendor, otherUnassigned, ownUnassigned); + await context.SaveChangesAsync(); + AddActiveDispatch(context, otherAveta); + var vendor = context.Vendors.First(); + var overlapping = Open(2, "dispatcher-1", Now.AddHours(1), "OTHER-OVERLAP"); + context.workOrders.Add(overlapping); + await context.SaveChangesAsync(); + context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = overlapping.Id, Status = "Sent", ScheduledDate = otherAveta.ScheduledDate }); + await context.SaveChangesAsync(); + + var admin = await NewService(context).GetFeedAsync(User(1, "Admin"), CancellationToken.None); + var dispatcher = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + admin.Sections.Select(section => section.Reason).Should().Equal(NotificationReasons.Unassigned); + Section(admin, NotificationReasons.Unassigned).Count.Should().Be(1); + dispatcher.Sections.Should().BeEmpty(); + var orgWide = await NewService(context).GetFeedAsync(OrgWideAdmin(), CancellationToken.None); + orgWide.Sections.Select(section => section.Reason).Should().Contain(new[] + { + NotificationReasons.Unassigned, NotificationReasons.NoVendor, + NotificationReasons.AvetaMissing, NotificationReasons.VendorConflict + }); + } + + [Fact] + public async Task VendorConflict_KeepsOverlappingAppointmentsAsTheirOwnSection() + { + using var context = NewContext(); + var mine = Open(1, "dispatcher-1", Now.AddDays(3), "C-MINE"); + var theirs = Open(1, "dispatcher-2", Now.AddDays(3), "C-THEIRS"); + context.workOrders.AddRange(mine, theirs); + await context.SaveChangesAsync(); + AddActiveDispatch(context, mine); + var vendor = context.Vendors.Single(); + context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = theirs.Id, Status = "Sent", ScheduledDate = theirs.ScheduledDate }); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + var unrelated = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-3"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.VendorConflict); + section.Label.Should().Be("Vendor Conflict"); + section.Count.Should().Be(2); + var item = section.Items.Single(); + item.Title.Should().Be("Gateway has overlapping appointments"); + item.WorkOrders.Select(workOrder => workOrder.Number).Should().BeEquivalentTo("C-MINE", "C-THEIRS"); + unrelated.Sections.Should().BeEmpty(); + } + + [Fact] + public async Task VendorConflict_CapsByRecencyKeepingNewerOverlaps() + { + using var context = NewContext(); + // One vendor whose older cluster produces more overlapping pairs than the cap, plus a + // single newer overlap. Build sweeps a vendor's windows in start order, so the newer pair + // is emitted last and is exactly what an unsorted Take(limit) would drop. + var oldStart = Now.AddDays(3); + var oldOrders = new List(); + for (var index = 0; index < 11; index++) + oldOrders.Add(Open(1, "dispatcher-1", oldStart, $"OLD-{index}")); + var newerLeft = Open(1, "dispatcher-1", Now.AddDays(10), "NEW-A"); + var newerRight = Open(1, "dispatcher-1", Now.AddDays(10), "NEW-B"); + context.workOrders.AddRange(oldOrders); + context.workOrders.AddRange(newerLeft, newerRight); + await context.SaveChangesAsync(); + + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + foreach (var workOrder in oldOrders.Append(newerLeft).Append(newerRight)) + context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = workOrder.Id, Status = "Sent", ScheduledDate = workOrder.ScheduledDate }); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.VendorConflict); + section.Items.Should().HaveCount(NotificationFeedService.SectionItemLimit); + section.Items.Should().Contain(item => + item.WorkOrders.Any(workOrder => workOrder.Number == "NEW-A") + && item.WorkOrders.Any(workOrder => workOrder.Number == "NEW-B")); + } + + [Fact] + public async Task PerWorkOrderSections_AreBoundedButCountEveryWorkOrder() + { + using var context = NewContext(); + var total = NotificationFeedService.SectionItemLimit + 5; + for (var index = 0; index < total; index++) + context.workOrders.Add(Open(1, "dispatcher-1", Now.AddHours(1).AddMinutes(index), $"B-{index}")); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.NoVendor); + section.Count.Should().Be(total); + section.Items.Should().HaveCount(NotificationFeedService.SectionItemLimit); + } + + [Fact] + public async Task MissingAccountScope_FailsClosed() + { + using var context = NewContext(); + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "user-1"), + new Claim(ClaimTypes.Role, "Admin") + }, "test")); + + var act = () => NewService(context).GetFeedAsync(user, CancellationToken.None); + + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task UnknownRole_FailsClosed() + { + using var context = NewContext(); + + var act = () => NewService(context).GetFeedAsync(User(1, "Vendor"), CancellationToken.None); + + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Cancellation_ReachesTheFeedQueries() + { + using var context = NewContext(); + context.workOrders.Add(Open(1)); + await context.SaveChangesAsync(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var act = () => NewService(context).GetFeedAsync(User(1, "Admin"), cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Controller_MapsForbiddenScopeTo403() + { + using var context = NewContext(); + var controller = new NotificationsController(NewService(context), Mock.Of()) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = User(1, "Vendor") } + } + }; + + var result = await controller.GetFeed(CancellationToken.None); + + result.Should().BeOfType().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + } + + [Fact] + public async Task Controller_ReturnsTheFeedForAnAuthorizedUser() + { + using var context = NewContext(); + context.workOrders.Add(Open(1)); + await context.SaveChangesAsync(); + var controller = new NotificationsController(NewService(context), Mock.Of()) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = User(1, "Admin") } + } + }; + + var result = await controller.GetFeed(CancellationToken.None); + + var feed = result.Should().BeOfType().Which.Value.Should().BeOfType().Subject; + feed.Sections.Single().Reason.Should().Be(NotificationReasons.Unassigned); + } + + [Fact] + public void Controller_RequiresAnAuthenticatedCaller() + { + typeof(NotificationsController).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) + .Should().NotBeEmpty(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs b/Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs new file mode 100644 index 0000000..2df8a3f --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs @@ -0,0 +1,402 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// SEV response-window alerts in the Notification Center: at risk from 50% of the window (banner set and a +/// dismissable row), breached from 100% (an acknowledge row that only acknowledging removes). +/// +public class NotificationSlaTests +{ + private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc); + + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(Now); + } + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderAccountResolver Resolver(ApplicationDbContext context) + => new(new AccountDataService(context), new LocationDataService(context)); + + private static NotificationFeedService NewFeed(ApplicationDbContext context) + => new( + new NotificationFeedDataService(context), + new VendorOperationsDataService(context, new DispatchDataService(context)), + Resolver(context), + new FixedTimeProvider()); + + private static SlaBreachAcknowledgementService NewAcknowledgement(ApplicationDbContext context) + => new( + new NotificationFeedDataService(context), + new WorkOrderAuditDataService(context), + new UserDataService(context), + Resolver(context), + new FixedTimeProvider()); + + private static ClaimsPrincipal User(int accountId, string role, string userId = "disp-1") + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) + }, "test")); + + private static WorkOrder Sla( + int id, + TimeSpan age, + string? severity = "1", + WorkOrderType? type = WorkOrderType.Reactive, + int accountId = 1, + string? assignTo = "disp-1", + DateTime? scheduled = null, + LifecycleStatus status = LifecycleStatus.Scheduled) + => new() + { + Id = id, + AccountId = accountId, + AssignTo = assignTo, + InternalWONumber = $"{1000 + id}", + WorkOrderType = type, + Severity = severity, + CreatedDate = Now - age, + ScheduledDate = scheduled, + LifecycleStatus = status + }; + + private static TimeSpan Window(int severity) => SlaResponseWindows.Respond[severity]; + + private static NotificationSectionDto? SlaSection(NotificationFeedDto feed) + => feed.Sections.SingleOrDefault(section => section.Reason == NotificationReasons.Sla); + + private static IEnumerable ItemIds(NotificationFeedDto feed) + => SlaSection(feed)?.Items.Select(item => item.Id) ?? Enumerable.Empty(); + + [Fact] + public void ResponseWindows_AreTheRespondDeadlinesOnTheSeverityBadge() + { + SlaResponseWindows.Respond.ToDictionary(pair => pair.Key, pair => pair.Value.TotalMinutes) + .Should().Equal(new Dictionary + { + [1] = 120, + [2] = 240, + [3] = 480, + [4] = 1440, + [5] = 4320 + }); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public async Task Thresholds_AtRiskFromHalfTheWindow_BreachedFromTheWholeWindow(int severity) + { + using var context = NewContext(); + var second = TimeSpan.FromSeconds(1); + var window = Window(severity); + var level = severity.ToString(); + context.workOrders.AddRange( + Sla(1, window / 2 - second, level), + Sla(2, window / 2, level), + Sla(3, window / 2 + second, level), + Sla(4, window - second, level), + Sla(5, window, level), + Sla(6, window + second, level)); + await context.SaveChangesAsync(); + + var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); + + feed.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 2, 3, 4 }); + ItemIds(feed).Should().BeEquivalentTo( + "sla-at-risk-2", "sla-at-risk-3", "sla-at-risk-4", "sla-breach-5", "sla-breach-6"); + + var section = SlaSection(feed)!; + section.Label.Should().Be("SLA at Risk"); + section.Count.Should().Be(5); + section.Severity.Should().Be(NotificationSeverities.Critical); + section.Items.Where(item => item.Id.StartsWith("sla-breach-")).Should().OnlyContain(item => + item.RowType == NotificationRowTypes.Acknowledge + && item.Severity == NotificationSeverities.Critical + && item.Title.EndsWith("missed its response deadline")); + section.Items.Where(item => item.Id.StartsWith("sla-at-risk-")).Should().OnlyContain(item => + item.RowType == NotificationRowTypes.Dismissable + && item.Severity == NotificationSeverities.High + && item.Title.EndsWith("is at risk of missing its response deadline")); + } + + [Fact] + public async Task AtRisk_CarriesTheServerComputedClock() + { + using var context = NewContext(); + context.workOrders.Add(Sla(7, TimeSpan.FromMinutes(90), "1")); + await context.SaveChangesAsync(); + + var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); + + var atRisk = feed.SlaAtRisk.Should().ContainSingle().Subject; + atRisk.Number.Should().Be("1007"); + atRisk.Severity.Should().Be(1); + atRisk.StartedAt.Should().Be(Now.AddMinutes(-90)); + atRisk.DeadlineAt.Should().Be(Now.AddMinutes(30)); + atRisk.DeadlineAt.Kind.Should().Be(DateTimeKind.Utc); + atRisk.PercentElapsed.Should().Be(75); + SlaSection(feed)!.Items.Single().Title.Should().Be("WO #1007 is at risk of missing its response deadline"); + } + + [Fact] + public async Task OnlyOpenReactiveOrEmergencyWorkOrdersWithASeverityLevelAreTracked() + { + using var context = NewContext(); + var late = Window(1) * 2; + context.workOrders.AddRange( + Sla(1, late, "1", WorkOrderType.Reactive), + Sla(2, late, "2", WorkOrderType.Emergency), + Sla(3, late, "1", WorkOrderType.PM), + Sla(4, late, "1", WorkOrderType.Inspection), + Sla(5, late, "1", type: null), + Sla(6, late, severity: null), + Sla(7, late, severity: "9"), + Sla(8, late, "1", status: LifecycleStatus.Completed), + Sla(9, late, "1", status: LifecycleStatus.Canceled)); + await context.SaveChangesAsync(); + + var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); + + ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2"); + } + + [Fact] + public async Task TheClockStartsAtCreation_WhateverTheScheduledDate() + { + using var context = NewContext(); + context.workOrders.AddRange( + Sla(1, Window(3) + TimeSpan.FromMinutes(1), "3", scheduled: null), + // Scheduled next week, but logged three days ago: the deadline is long gone. + Sla(2, TimeSpan.FromDays(3), "4", scheduled: Now.AddDays(7)), + // Scheduled days ago, but logged ten minutes ago: nothing is due yet. + Sla(3, TimeSpan.FromMinutes(10), "4", scheduled: Now.AddDays(-5))); + await context.SaveChangesAsync(); + + var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); + + ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2"); + feed.SlaAtRisk.Should().BeEmpty(); + } + + [Fact] + public async Task Dispatcher_OnlySeesTheirOwnWorkOrders_AndTheAccountStaysTheBoundary() + { + using var context = NewContext(); + var atRisk = Window(2) * 3 / 4; + context.workOrders.AddRange( + Sla(1, atRisk, "2", assignTo: "disp-1"), + Sla(2, atRisk, "2", assignTo: "disp-2"), + Sla(3, atRisk, "2", assignTo: null), + Sla(4, atRisk, "2", assignTo: "disp-1", accountId: 2)); + await context.SaveChangesAsync(); + + var dispatcher = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher", "disp-1"), CancellationToken.None); + var admin = await NewFeed(context).GetFeedAsync(User(1, "Admin", "admin-1"), CancellationToken.None); + var otherAccount = await NewFeed(context).GetFeedAsync(User(2, "Admin", "admin-2"), CancellationToken.None); + + dispatcher.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(1); + ItemIds(dispatcher).Should().Equal("sla-at-risk-1"); + admin.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 1, 2, 3 }); + otherAccount.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(4); + } + + [Fact] + public async Task Breaches_AreCappedSeparately_SoAtRiskRowsAlwaysShow() + { + using var context = NewContext(); + for (var id = 1; id <= NotificationFeedService.SectionItemLimit + 10; id++) + context.workOrders.Add(Sla(id, Window(1) + TimeSpan.FromMinutes(id), "1")); + context.workOrders.Add(Sla(500, Window(5) * 3 / 4, "5")); + await context.SaveChangesAsync(); + + var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); + + var section = SlaSection(feed)!; + section.Count.Should().Be(NotificationFeedService.SectionItemLimit + 11); + section.Items.Count(item => item.RowType == NotificationRowTypes.Acknowledge) + .Should().Be(NotificationFeedService.SectionItemLimit); + section.Items.Should().Contain(item => item.Id == "sla-at-risk-500"); + // The newest breaches are kept. + section.Items.Should().Contain(item => item.Id == "sla-breach-1"); + section.Items.Should().NotContain(item => item.Id == $"sla-breach-{NotificationFeedService.SectionItemLimit + 10}"); + } + + [Fact] + public async Task Acknowledge_RecordsWhoAndWhenInTheAuditHistory_AndRemovesTheRow() + { + using var context = NewContext(); + context.Users.Add(new ApplicationUser { Id = "disp-1", UserName = "jane", FirstName = "Jane", LastName = "Doe" }); + context.workOrders.AddRange( + Sla(1, Window(1) + TimeSpan.FromMinutes(5), "1"), + Sla(2, Window(1) + TimeSpan.FromMinutes(9), "1")); + await context.SaveChangesAsync(); + var user = User(1, "Dispatcher", "disp-1"); + + var outcome = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None); + + outcome.Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged); + var audit = await context.WorkOrderAuditLogs.SingleAsync(); + audit.WorkOrderId.Should().Be(1); + audit.UserId.Should().Be("disp-1"); + audit.CreatedAt.Should().Be(Now); + audit.Action.Should().Be("SLA breach acknowledged by Jane Doe"); + audit.FieldName.Should().Be(SlaBreachAcknowledgementAudit.FieldName); + audit.NewValue.Should().Be("1"); + audit.EventType.Should().Be("system"); + + var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None); + ItemIds(feed).Should().Equal("sla-breach-2"); + SlaSection(feed)!.Count.Should().Be(1); + + var again = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None); + again.Should().Be(SlaBreachAcknowledgementOutcome.AlreadyAcknowledged); + (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Acknowledgement_CoversOnlyTheSeverityThatWasBreached() + { + using var context = NewContext(); + var workOrder = Sla(1, TimeSpan.FromHours(30), "2"); + context.workOrders.Add(workOrder); + await context.SaveChangesAsync(); + var user = User(1, "Dispatcher", "disp-1"); + await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None); + + workOrder.Severity = "4"; + await context.SaveChangesAsync(); + var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None); + + ItemIds(feed).Should().Equal("sla-breach-1"); + } + + [Fact] + public async Task Acknowledge_IsRefusedOutsideTheCallersFeed() + { + using var context = NewContext(); + var late = Window(1) * 2; + context.workOrders.AddRange( + Sla(1, late, "1", accountId: 2, assignTo: "disp-1"), + Sla(2, late, "1", assignTo: "disp-2"), + Sla(3, late, "1", status: LifecycleStatus.Completed), + Sla(4, late, "1", WorkOrderType.PM)); + await context.SaveChangesAsync(); + var service = NewAcknowledgement(context); + var dispatcher = User(1, "Dispatcher", "disp-1"); + + (await service.AcknowledgeAsync(dispatcher, 1, CancellationToken.None)) + .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); + (await service.AcknowledgeAsync(User(1, "Admin", "admin-1"), 1, CancellationToken.None)) + .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); + (await service.AcknowledgeAsync(dispatcher, 2, CancellationToken.None)) + .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); + (await service.AcknowledgeAsync(dispatcher, 3, CancellationToken.None)) + .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); + (await service.AcknowledgeAsync(dispatcher, 4, CancellationToken.None)) + .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); + (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0); + + var otherAccountAdmin = User(2, "Admin", "admin-2"); + (await service.AcknowledgeAsync(otherAccountAdmin, 1, CancellationToken.None)) + .Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged); + } + + [Fact] + public async Task Acknowledge_BeforeTheDeadlineIsRefused() + { + using var context = NewContext(); + context.workOrders.Add(Sla(1, Window(1) * 3 / 4, "1")); + await context.SaveChangesAsync(); + + var outcome = await NewAcknowledgement(context) + .AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, CancellationToken.None); + + outcome.Should().Be(SlaBreachAcknowledgementOutcome.NotBreached); + (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Acknowledge_WithoutANotificationRole_FailsClosed() + { + using var context = NewContext(); + context.workOrders.Add(Sla(1, Window(1) * 2, "1")); + await context.SaveChangesAsync(); + + var act = () => NewAcknowledgement(context).AcknowledgeAsync(User(1, "Vendor"), 1, CancellationToken.None); + + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Theory] + [InlineData(1, "Dispatcher", 1, StatusCodes.Status204NoContent)] + [InlineData(2, "Admin", 1, StatusCodes.Status404NotFound)] + [InlineData(1, "Dispatcher", 2, StatusCodes.Status409Conflict)] + [InlineData(1, "Vendor", 1, StatusCodes.Status403Forbidden)] + public async Task Controller_MapsAcknowledgeOutcomes(int accountId, string role, int workOrderId, int expectedStatus) + { + using var context = NewContext(); + context.workOrders.AddRange( + Sla(1, Window(1) * 2, "1"), + Sla(2, Window(1) * 3 / 4, "1")); + await context.SaveChangesAsync(); + var controller = new NotificationsController(NewFeed(context), NewAcknowledgement(context)) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = User(accountId, role, "disp-1") } + } + }; + + var result = await controller.AcknowledgeSlaBreach(workOrderId, CancellationToken.None); + + result.Should().BeAssignableTo() + .Which.StatusCode.Should().Be(expectedStatus); + } + + [Fact] + public async Task Acknowledge_ForwardsCancellation() + { + using var context = NewContext(); + context.workOrders.Add(Sla(1, Window(1) * 2, "1")); + await context.SaveChangesAsync(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var act = () => NewAcknowledgement(context) + .AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, cancellation.Token); + + await act.Should().ThrowAsync(); + (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0); + } +} diff --git a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs new file mode 100644 index 0000000..117e3a7 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs @@ -0,0 +1,235 @@ +using Api.SeaHavenIndustries.Infrastructure; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// Exercises the password rule through the same Identity registration the API +/// host uses, so these assertions describe the rule that runs in production. +/// +public sealed class PasswordPolicyTests : IAsyncDisposable +{ + private const string CurrentPassword = "Current1!"; + private readonly ServiceProvider _provider; + private readonly AsyncServiceScope _scope; + + public PasswordPolicyTests() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddSeaHavenIdentity(); + _provider = services.BuildServiceProvider(); + _scope = _provider.CreateAsyncScope(); + } + + private UserManager UserManager => + _scope.ServiceProvider.GetRequiredService>(); + + [Theory] + [InlineData("Ab1!x", "PasswordTooShort")] + [InlineData("abc12!", "PasswordRequiresUpper")] + [InlineData("Abcde!", "PasswordRequiresDigit")] + [InlineData("Abcde1", "PasswordRequiresNonAlphanumeric")] + public async Task Policy_RejectsPasswordMissingOneRule(string password, string expectedCode) + { + var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" }; + + var errors = await ValidateAsync(user, password); + + errors.Select(error => error.Code).Should().Equal(expectedCode); + } + + [Theory] + [InlineData("Abc1!x")] + [InlineData("ABC12!")] + public async Task Policy_AcceptsSixCharacterPasswordMeetingEveryRule(string password) + { + var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" }; + + var errors = await ValidateAsync(user, password); + + errors.Should().BeEmpty(); + } + + [Fact] + public void Registration_AppliesSharedPolicyOptions() + { + var options = _scope.ServiceProvider.GetRequiredService>().Value.Password; + + options.RequiredLength.Should().Be(6); + options.RequireUppercase.Should().BeTrue(); + options.RequireDigit.Should().BeTrue(); + options.RequireNonAlphanumeric.Should().BeTrue(); + options.RequireLowercase.Should().BeFalse(); + } + + [Fact] + public async Task ChangePassword_WrongCurrentPassword_IsRejectedBeforeNewPasswordIsEvaluated() + { + var user = await CreateUserAsync(); + var service = NewAuthenticationService(); + + var result = await service.ChangePasswordAsync(user.Id, "Wrong1!", "weak", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.CurrentPasswordIncorrect); + (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); + } + + [Fact] + public async Task ChangePassword_CorrectCurrentPasswordAndWeakNewPassword_IsRejectedByPolicy() + { + var user = await CreateUserAsync(); + var service = NewAuthenticationService(); + + var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "abcdef", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.PasswordRejected); + (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); + } + + [Fact] + public async Task ChangePassword_CorrectCurrentPasswordAndCompliantNewPassword_ChangesPassword() + { + var user = await CreateUserAsync(); + var service = NewAuthenticationService(); + + var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.Succeeded); + var reloaded = await UserManager.FindByIdAsync(user.Id); + (await UserManager.CheckPasswordAsync(reloaded!, "Next2@x")).Should().BeTrue(); + } + + [Theory] + [InlineData("ConcurrencyFailure")] + [InlineData("PasswordMismatch")] + [InlineData("DefaultError")] + public async Task ChangePassword_NonPolicyIdentityFailure_IsNotReportedAsAWeakPassword(string code) + { + var user = new ApplicationUser { Id = "member-1", UserName = "member@example.com" }; + var userManager = new Mock>( + Mock.Of>(), null!, null!, null!, null!, null!, null!, null!, null!); + userManager.Setup(m => m.FindByIdAsync(user.Id)).ReturnsAsync(user); + userManager.Setup(m => m.CheckPasswordAsync(user, CurrentPassword)).ReturnsAsync(true); + userManager.Setup(m => m.ChangePasswordAsync(user, CurrentPassword, "Next2@x")) + .ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = code, Description = "failed" })); + var service = new AuthenticationService( + userManager.Object, + Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }), + Mock.Of(), + Mock.Of(), + Mock.Of(), + new InMemoryPasswordResetThrottle(TimeProvider.System), + TimeProvider.System, + Mock.Of()); + + var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.Failed); + } + + [Theory] + [InlineData("PasswordTooShort", true)] + [InlineData("PasswordRequiresUpper", true)] + [InlineData("PasswordRequiresDigit", true)] + [InlineData("PasswordRequiresNonAlphanumeric", true)] + [InlineData("ConcurrencyFailure", false)] + [InlineData("PasswordMismatch", false)] + public void IsPolicyRejection_MatchesOnlyThePasswordRuleCodes(string code, bool expected) + { + IdentityPasswordPolicy.IsPolicyRejection(IdentityResult.Failed(new IdentityError { Code = code })) + .Should().Be(expected); + } + + [Fact] + public async Task ChangePassword_CancelledToken_Throws() + { + var service = NewAuthenticationService(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var act = () => service.ChangePasswordAsync("any", CurrentPassword, "Next2@x", cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode() + { + var user = await CreateUserAsync(); + var forget = new Mock(); + var salt = PasswordResetCodeSecrets.NewSalt(); + forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny())) + .ReturnsAsync(new ForgetPasswordCode + { + Id = 7, + Email = user.Email!, + UserId = user.Id, + CodeSalt = salt, + CodeHash = PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.DeriveKey(new string('x', 64)), salt, "123456"), + ExpiresAtUtc = DateTime.UtcNow.AddMinutes(10) + }); + forget.Setup(f => f.TryConsumeAttemptAsync(7, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + var service = NewAuthenticationService(forget); + + var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None); + + reset.Should().BeFalse(); + (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); + forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + forget.Verify(f => f.RefundAttemptAsync(7, It.IsAny()), Times.Once); + } + + private async Task> ValidateAsync(ApplicationUser user, string password) + { + var errors = new List(); + foreach (var validator in UserManager.PasswordValidators) + { + var result = await validator.ValidateAsync(UserManager, user, password); + errors.AddRange(result.Errors); + } + + return errors; + } + + private async Task CreateUserAsync() + { + var user = new ApplicationUser { UserName = "member@example.com", Email = "member@example.com" }; + var created = await UserManager.CreateAsync(user, CurrentPassword); + created.Succeeded.Should().BeTrue(); + return user; + } + + private AuthenticationService NewAuthenticationService(Mock? forget = null) => + new( + UserManager, + Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }), + Mock.Of(), + (forget ?? new Mock()).Object, + Mock.Of(), + new InMemoryPasswordResetThrottle(TimeProvider.System), + TimeProvider.System, + Mock.Of()); + + public async ValueTask DisposeAsync() + { + await _scope.DisposeAsync(); + await _provider.DisposeAsync(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/PasswordResetEmailChannelTests.cs b/Api.SeaHavenIndustries.Tests/PasswordResetEmailChannelTests.cs new file mode 100644 index 0000000..5366173 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/PasswordResetEmailChannelTests.cs @@ -0,0 +1,43 @@ +using Api.SeaHavenIndustries.HostedServices; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class PasswordResetEmailChannelTests +{ + [Fact] + public void A_full_queue_refuses_the_email_instead_of_dropping_it_silently() + { + var channel = new PasswordResetEmailChannel(NullLogger.Instance); + for (var i = 0; i < PasswordResetEmailChannel.Capacity; i++) + channel.TryEnqueue($"user{i}@example.com", "subject", "body").Should().BeTrue(); + + var accepted = channel.TryEnqueue("late@example.com", "subject", "body"); + + accepted.Should().BeFalse(); + channel.Pending.Should().Be(PasswordResetEmailChannel.Capacity); + } + + [Fact] + public async Task A_refused_email_is_never_delivered_and_the_queue_accepts_again_once_drained() + { + var channel = new PasswordResetEmailChannel(NullLogger.Instance); + for (var i = 0; i < PasswordResetEmailChannel.Capacity; i++) + channel.TryEnqueue($"user{i}@example.com", "subject", "body"); + channel.TryEnqueue("late@example.com", "subject", "body").Should().BeFalse(); + + var delivered = new List(); + while (channel.Reader.TryRead(out var email)) + { + delivered.Add(email.EmailTo); + channel.MarkHandled(); + } + + delivered.Should().HaveCount(PasswordResetEmailChannel.Capacity).And.NotContain("late@example.com"); + channel.Pending.Should().Be(0); + channel.TryEnqueue("retry@example.com", "subject", "body").Should().BeTrue(); + (await channel.Reader.ReadAsync()).EmailTo.Should().Be("retry@example.com"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/PasswordResetEmailSenderTracingTests.cs b/Api.SeaHavenIndustries.Tests/PasswordResetEmailSenderTracingTests.cs new file mode 100644 index 0000000..93c912a --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/PasswordResetEmailSenderTracingTests.cs @@ -0,0 +1,55 @@ +using Api.SeaHavenIndustries.HostedServices; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Sentry; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class PasswordResetEmailSenderTracingTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Each_send_finishes_its_transaction_as_ok_only_when_the_provider_accepts_it(bool accepted) + { + var transaction = new Mock(); + var hub = new Mock(); + hub.Setup(h => h.PushScope()).Returns(Mock.Of()); + hub.Setup(h => h.StartTransaction(It.IsAny(), It.IsAny>())) + .Returns(transaction.Object); + + var sender = new Mock(); + sender.Setup(s => s.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(accepted); + var services = new ServiceCollection().AddSingleton(sender.Object).BuildServiceProvider(); + var channel = new PasswordResetEmailChannel(NullLogger.Instance); + var worker = new PasswordResetEmailSenderHostedService( + channel, + services.GetRequiredService(), + NullLogger.Instance, + hub.Object); + + await worker.StartAsync(CancellationToken.None); + Assert.True(channel.TryEnqueue("user@example.com", "subject", "Your code is 123456")); + var deadline = DateTime.UtcNow.AddSeconds(10); + while (channel.Pending > 0 && DateTime.UtcNow < deadline) + await Task.Delay(10); + await worker.StopAsync(CancellationToken.None); + + Assert.Equal(0, channel.Pending); + if (accepted) + { + transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Once); + transaction.Verify(t => t.Finish(It.IsAny(), It.IsAny()), Times.Never); + } + else + { + transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Never); + transaction.Verify(t => t.Finish( + It.Is(ex => !ex.Message.Contains("user@example.com") && !ex.Message.Contains("123456")), + SpanStatus.InternalError), Times.Once); + } + } +} diff --git a/Api.SeaHavenIndustries.Tests/SendMessageTests.cs b/Api.SeaHavenIndustries.Tests/SendMessageTests.cs new file mode 100644 index 0000000..806bcd1 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SendMessageTests.cs @@ -0,0 +1,105 @@ +using System.Net; +using Api.SeaHavenIndustries.Helper; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using SendGrid; +using SendGrid.Helpers.Mail; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class SendMessageTests +{ + private const string Recipient = "taylor@example.com"; + private const string Subject = "You're invited to Seahaven"; + private const string RejectionBody = "{\"errors\":[{\"message\":\"taylor@example.com does not exist\"}]}"; + + [Theory] + [InlineData(HttpStatusCode.OK, true)] + [InlineData(HttpStatusCode.Accepted, true)] + [InlineData(HttpStatusCode.BadRequest, false)] + [InlineData(HttpStatusCode.Unauthorized, false)] + [InlineData(HttpStatusCode.TooManyRequests, false)] + [InlineData(HttpStatusCode.InternalServerError, false)] + public async Task EverySendPath_ReportsDeliveryOnlyForASuccessStatus(HttpStatusCode status, bool delivered) + { + var sender = new StubbedSendMessage(status, new CapturingLogger()); + + Assert.Equal(delivered, await sender.SendEMail(Recipient, Subject, "

Hi

")); + Assert.Equal(delivered, await sender.SendEmailAsync(Recipient, Subject, "

Hi

")); + Assert.Equal(delivered, await sender.SendEMailAttachment(Recipient, Subject, new byte[] { 1, 2, 3 })); + Assert.Equal(delivered, await sender.SendDispatchEmail(Recipient, Subject, "

Hi

", null)); + } + + [Fact] + public async Task ARejectedSend_LogsOnlyTheStatusCode() + { + var logger = new CapturingLogger(); + var sender = new StubbedSendMessage(HttpStatusCode.BadRequest, logger); + + Assert.False(await sender.SendEMail(Recipient, Subject, "

secret link

")); + + var entry = Assert.Single(logger.Entries); + Assert.Contains("400", entry); + Assert.DoesNotContain(Recipient, entry); + Assert.DoesNotContain(Subject, entry); + Assert.DoesNotContain("secret link", entry); + Assert.DoesNotContain("errors", entry); + } + + [Fact] + public async Task AFailedSend_LogsOnlyTheExceptionType() + { + var logger = new CapturingLogger(); + var sender = new StubbedSendMessage( + new HttpRequestException($"could not reach SendGrid for {Recipient}"), + logger); + + Assert.False(await sender.SendEMail(Recipient, Subject, "

Hi

")); + + var entry = Assert.Single(logger.Entries); + Assert.Contains(nameof(HttpRequestException), entry); + Assert.DoesNotContain(Recipient, entry); + } + + private sealed class StubbedSendMessage : SendMessage + { + private readonly Mock _client = new(); + + public StubbedSendMessage(HttpStatusCode status, ILogger logger) + : base(new ConfigurationBuilder().Build(), logger) + { + _client + .Setup(client => client.SendEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => new Response(status, new StringContent(RejectionBody), null)); + } + + public StubbedSendMessage(Exception failure, ILogger logger) + : base(new ConfigurationBuilder().Build(), logger) + { + _client + .Setup(client => client.SendEmailAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(failure); + } + + protected override ISendGridClient CreateClient(string? apiKey) => _client.Object; + } + + private sealed class CapturingLogger : ILogger + { + public List Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + Entries.Add($"{formatter(state, exception)} {exception}"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/SentryObservabilityTests.cs b/Api.SeaHavenIndustries.Tests/SentryObservabilityTests.cs index cb760e8..4b83b8d 100644 --- a/Api.SeaHavenIndustries.Tests/SentryObservabilityTests.cs +++ b/Api.SeaHavenIndustries.Tests/SentryObservabilityTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Security.Claims; using Api.SeaHavenIndustries.Observability; using FluentAssertions; @@ -14,6 +15,7 @@ namespace Api.SeaHavenIndustries.Tests; public class SentryObservabilityTests { private static readonly string ValidSha = new string('a', 40); + private static readonly string UpperSha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01"; [Fact] public void ResolveRelease_Accepts_ServicePrefixed40HexCommit() @@ -23,15 +25,57 @@ public class SentryObservabilityTests } [Theory] + [InlineData(null)] [InlineData("1.0.0")] [InlineData("shoc-backend@abc")] [InlineData("shoc-backend@not-a-sha-at-all")] - [InlineData("other-service@" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + [InlineData("other-service@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + [InlineData("SHOC-BACKEND@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] [InlineData("")] - public void ResolveRelease_FallsBackToLocalDevelopment_ForNonReleaseVersion(string version) + [InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + [InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + [InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")] + [InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+dirty")] + [InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+")] + [InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa++")] + [InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+extra")] + public void ResolveRelease_FallsBackToLocalDevelopment_ForNonReleaseVersion(string? version) { SentryObservability.ResolveRelease(version) .Should().Be(SentryObservability.LocalDevelopmentRelease); + SentryObservability.ResolveCommitSha(version) + .Should().BeNull(); + } + + [Fact] + public void ResolveRelease_Accepts_SdkAppendedMatchingSourceRevisionSuffix() + { + var informationalVersion = "shoc-backend@" + ValidSha + "+" + ValidSha; + + SentryObservability.ResolveRelease(informationalVersion) + .Should().Be("shoc-backend@" + ValidSha); + SentryObservability.ResolveCommitSha(informationalVersion) + .Should().Be(ValidSha); + } + + [Fact] + public void ResolveRelease_Accepts_SuffixDifferingOnlyInCase_FromTheCommitSha() + { + var canonicalSha = UpperSha.ToLowerInvariant(); + + SentryObservability.ResolveRelease("shoc-backend@" + UpperSha + "+" + canonicalSha) + .Should().Be("shoc-backend@" + canonicalSha); + } + + [Fact] + public void ResolveRelease_NormalizesUppercaseCommitShaToLowercase() + { + var canonicalSha = UpperSha.ToLowerInvariant(); + + SentryObservability.ResolveRelease("shoc-backend@" + UpperSha) + .Should().Be("shoc-backend@" + canonicalSha); + SentryObservability.ResolveCommitSha("shoc-backend@" + UpperSha) + .Should().Be(canonicalSha); } [Fact] @@ -39,6 +83,28 @@ public class SentryObservabilityTests { var release = SentryObservability.ResolveRelease("shoc-backend@" + ValidSha); release[(SentryObservability.ServiceName.Length + 1)..].Should().Be(ValidSha); + SentryObservability.ResolveCommitSha("shoc-backend@" + ValidSha) + .Should().Be(ValidSha); + } + + [Fact] + public void ResolveRelease_AndCommitSha_FromNonReleaseAssembly_FallBackSafely() + { + var assembly = typeof(SentryObservabilityTests).Assembly; + + SentryObservability.ResolveRelease(assembly) + .Should().Be(SentryObservability.LocalDevelopmentRelease); + SentryObservability.ResolveCommitSha(assembly) + .Should().BeNull(); + } + + [Fact] + public void ResolveRelease_AndCommitSha_FromNullAssembly_FallBackSafely() + { + SentryObservability.ResolveRelease((Assembly?)null) + .Should().Be(SentryObservability.LocalDevelopmentRelease); + SentryObservability.ResolveCommitSha((Assembly?)null) + .Should().BeNull(); } [Fact] diff --git a/Api.SeaHavenIndustries.Tests/SentryPipelineContractTests.cs b/Api.SeaHavenIndustries.Tests/SentryPipelineContractTests.cs index b5ff0d3..90f539b 100644 --- a/Api.SeaHavenIndustries.Tests/SentryPipelineContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/SentryPipelineContractTests.cs @@ -61,6 +61,7 @@ public class SentryPipelineContractTests [InlineData("Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs", "workorders.week-rolled-job")] [InlineData("Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs", "workorders.past-due-cache-job")] [InlineData("Api.SeaHavenIndustries/HostedServices/UpliftLifecycleHostedService.cs", "uplifts.lifecycle-sweep")] + [InlineData("Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs", "auth.password-reset-email")] public void Workers_UseSharedBackgroundTransactionHelper_WithStableNames(string relativePath, string transactionName) { var source = File.ReadAllText(Path.Combine(RepoRoot(), relativePath)); diff --git a/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs index 84da086..b3e0e3d 100644 --- a/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs +++ b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs @@ -72,4 +72,19 @@ public class ServiceRegistrationTests AssertScopedConventionRegistrations(services, candidates, "SeaHaven.DataServices"); } + + [Fact] + public void SessionStampCache_IsOneInstanceForTheWholeProcess() + { + // A scoped cache would never be hit, and a stamp change on one request would + // never evict the value another request cached. + using var provider = BuildConventionServices().BuildServiceProvider(); + using var first = provider.CreateScope(); + using var second = provider.CreateScope(); + + var cache = first.ServiceProvider.GetRequiredService(); + + cache.Should().BeOfType(); + second.ServiceProvider.GetRequiredService().Should().BeSameAs(cache); + } } diff --git a/Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs b/Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs new file mode 100644 index 0000000..c1d6cf0 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs @@ -0,0 +1,185 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class ServicesRegistryControllerTests +{ + private static ServicesRegistryController NewController(Mock service, params string[] roles) + { + var controller = new ServicesRegistryController(service.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test")) + } + } + }; + return controller; + } + + private static ServicesRegistryValidationErrorDto? ErrorValue(IActionResult result) => + (result as ObjectResult)?.Value as ServicesRegistryValidationErrorDto; + + [Fact] + public async Task GetAll_ReturnsOkWithServices() + { + var service = new Mock(); + service.Setup(s => s.GetAllAsync(true, WorkOrderType.PM, It.IsAny())) + .ReturnsAsync(new List { new() { Id = 1, Name = "Leak", IsActive = true } }); + + var result = await NewController(service, "Admin").GetAll(true, WorkOrderType.PM, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + (ok.Value as IEnumerable).Should().ContainSingle(s => s.Name == "Leak"); + } + + [Fact] + public async Task GetAll_WhenValidationCode_ReturnsBadRequestWithStableCodeAndMessage() + { + var service = new Mock(); + service.Setup(s => s.GetAllAsync(null, WorkOrderType.Project, It.IsAny())) + .Throws(new ServicesRegistryValidationException( + "WorkOrderTypeInvalid", "Supported work order types may only include PM, Reactive, or Emergency.")); + + var result = await NewController(service, "Admin").GetAll(null, WorkOrderType.Project, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + ErrorValue(bad)!.Code.Should().Be("WorkOrderTypeInvalid"); + ErrorValue(bad)!.Message.Should().Be("Supported work order types may only include PM, Reactive, or Emergency."); + } + + [Fact] + public async Task GetById_WhenMissing_ReturnsNotFoundWithCode() + { + var service = new Mock(); + service.Setup(s => s.GetByIdAsync(9, It.IsAny())) + .ReturnsAsync((ServiceDto?)null); + + var result = await NewController(service, "Admin").GetById(9, CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + ErrorValue(notFound)!.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task Create_PassesCallerAndReturnsCreated() + { + var service = new Mock(); + service.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ServiceDto { Id = 42, Name = "Leak", IsActive = true }); + + var result = await NewController(service, "Scheduler").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None); + + var created = result.Should().BeOfType().Subject; + created.StatusCode.Should().Be(StatusCodes.Status201Created); + (created.Value as ServiceDto)!.Id.Should().Be(42); + service.Verify(s => s.CreateAsync( + It.Is(p => p.IsInRole("Scheduler")), + It.Is(i => i.Name == "Leak"), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Create_WhenForbidden_Returns403WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required.")); + + var result = await NewController(service, "User").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + ErrorValue(forbidden)!.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Create_WhenDuplicateName_Returns400WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists.")); + + var result = await NewController(service, "Admin").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + ErrorValue(bad)!.Code.Should().Be("DuplicateName"); + } + + [Fact] + public async Task Update_ReturnsOkWithUpdatedService() + { + var service = new Mock(); + service.Setup(s => s.UpdateAsync(It.IsAny(), 7, It.IsAny(), It.IsAny())) + .ReturnsAsync(new ServiceDto { Id = 7, Name = "Renamed" }); + + var result = await NewController(service, "Admin").Update(7, new ServiceInput { Name = "Renamed" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + (ok.Value as ServiceDto)!.Name.Should().Be("Renamed"); + } + + [Fact] + public async Task Update_WhenNotFound_Returns404WithCode() + { + var service = new Mock(); + service.Setup(s => s.UpdateAsync(It.IsAny(), 404, It.IsAny(), It.IsAny())) + .Throws(new ServicesRegistryValidationException("NotFound", "Service not found.")); + + var result = await NewController(service, "Admin").Update(404, new ServiceInput(), CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + ErrorValue(notFound)!.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task Deactivate_ReturnsOk() + { + var service = new Mock(); + + var result = await NewController(service, "Admin").Deactivate(3, CancellationToken.None); + + result.Should().BeOfType(); + service.Verify(s => s.DeactivateAsync( + It.Is(p => p.IsInRole("Admin")), 3, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Deactivate_WhenForbidden_Returns403WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.DeactivateAsync(It.IsAny(), 3, It.IsAny())) + .Throws(new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services.")); + + var result = await NewController(service, "Scheduler").Deactivate(3, CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + ErrorValue(forbidden)!.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Deactivate_WhenNotFound_Returns404WithCode() + { + var service = new Mock(); + service.Setup(s => s.DeactivateAsync(It.IsAny(), 404, It.IsAny())) + .Throws(new ServicesRegistryValidationException("NotFound", "Service not found.")); + + var result = await NewController(service, "Admin").Deactivate(404, CancellationToken.None); + + result.Should().BeOfType(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs b/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs new file mode 100644 index 0000000..9eba754 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs @@ -0,0 +1,396 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +// Global Services registry behavior. The registry has no tenant scope +// (no AccountId), authorization is enforced at the service boundary, and the +// only allowed supported work-order types are PM, Reactive, and Emergency. +public class ServicesRegistryServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ServicesRegistryService NewService(ApplicationDbContext ctx) => + new(new ServicesRegistryDataService(ctx)); + + private static ClaimsPrincipal Principal(params string[] roles) => + new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test")); + + private static ClaimsPrincipal Admin() => Principal("Admin"); + private static ClaimsPrincipal Scheduler() => Principal("Scheduler"); + + private static ServiceInput Input( + string name = "Leak", + string? trade = "Plumbing & Water Systems", + string? iconKey = "plumbing-water-systems", + bool requiresDoc = false, + int? templateId = null, + List? types = null) => new() + { + Name = name, + Trade = trade, + IconKey = iconKey, + RequiresCompletionDocument = requiresDoc, + CompletionDocTemplateId = templateId, + SupportedWorkOrderTypes = types ?? new List + { + WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency + } + }; + + private static CompletionDocTemplate Template(string name, bool active = true, bool deleted = false) => + new() { Name = name, ServiceKey = "test", TemplateUrl = "https://example.com/t.pdf", IsActive = active, IsDeleted = deleted }; + + [Fact] + public async Task Create_PersistsActiveServiceWithNormalizedFieldsAndSupportedTypes() + { + using var ctx = NewContext(); + + var dto = await NewService(ctx).CreateAsync(Scheduler(), Input(" Leak Detection "), CancellationToken.None); + + dto.Id.Should().BeGreaterThan(0); + dto.Name.Should().Be("Leak Detection"); + dto.Trade.Should().Be("Plumbing & Water Systems"); + dto.IconKey.Should().Be("plumbing-water-systems"); + dto.IsActive.Should().BeTrue("new services are always active"); + dto.CompletionDocTemplate.Should().BeNull(); + dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency }); + + var row = ctx.Services.Include(s => s.SupportedWorkOrderTypes).Single(); + row.NormalizedName.Should().Be("leak detection"); + } + + [Fact] + public async Task Create_WhenTypeCategoriesOmitted_DefaultsToSupportedWorkOrderTypes() + { + using var ctx = NewContext(); + + var input = Input(types: null); + input.SupportedWorkOrderTypes = null; + + var dto = await NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None); + + dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] + { + WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency + }); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Create_WhenNameBlank_ThrowsNameRequired(string? name) + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Admin(), Input(name: name!), CancellationToken.None); + var ex = (await act.Should().ThrowAsync()).Subject.Single(); + ex.Code.Should().Be("NameRequired"); + } + + [Fact] + public async Task Create_WhenTradeNotCanonical_ThrowsTradeInvalid() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Admin(), Input(trade: "Roofing"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("TradeInvalid"); + } + + [Theory] + [InlineData(" hvac ")] + [InlineData("HVAC")] + [InlineData("hvac")] + public async Task Create_AcceptsCanonicalTradeInAnyCasingOrPadding(string trade) + { + using var ctx = NewContext(); + var dto = await NewService(ctx).CreateAsync(Admin(), Input(name: "Cooling", trade: trade, iconKey: "hvac"), CancellationToken.None); + dto.Trade.Should().Be("HVAC"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Create_WhenIconKeyBlank_ThrowsIconKeyInvalid(string? iconKey) + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Admin(), Input(iconKey: iconKey!), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("IconKeyInvalid"); + } + + [Fact] + public async Task Create_WhenNormalizedNameExists_ThrowsDuplicateName_RegardlessOfCasing() + { + using var ctx = NewContext(); + var service = NewService(ctx); + await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + var act = () => service.CreateAsync(Admin(), Input(" LEAK "), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("DuplicateName"); + } + + [Fact] + public async Task Registry_IsGlobal_SingleScopeWithoutAccountOrTenantFilter() + { + using var ctx = NewContext(); + var service = NewService(ctx); + // Distinct actors with different role sets still share one global registry. + var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + var act = () => service.CreateAsync(Scheduler(), Input("Leak"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("DuplicateName"); + + var all = await service.GetAllAsync(null, null, CancellationToken.None); + all.Should().ContainSingle(s => s.Id == first.Id); + typeof(Service).GetProperty("AccountId").Should().BeNull("tenant scope is global, matching DropdownOptions"); + } + + [Fact] + public async Task Create_WhenCallerLacksSchedulerAndAdminRole_ThrowsForbidden() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Principal("User"), Input(), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Create_WhenCallerUnauthenticated_ThrowsForbidden() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Principal(), Input(), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Create_WhenTemplateMissing_Inactive_OrDeleted_ThrowsTemplateInvalid() + { + using var ctx = NewContext(); + + (await new Func>(() => NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: 999), CancellationToken.None)) + .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); + + ctx.CompletionDocTemplates.Add(Template("Inactive", active: false)); + ctx.CompletionDocTemplates.Add(Template("Deleted", deleted: true)); + ctx.SaveChanges(); + + (await new Func>(() => NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Inactive").Id), CancellationToken.None)) + .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); + + (await new Func>(() => NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Deleted").Id), CancellationToken.None)) + .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); + } + + [Fact] + public async Task Create_WithActiveTemplate_ProjectsLinkedTemplateSummary() + { + using var ctx = NewContext(); + var template = Template("Completion Packet"); + ctx.CompletionDocTemplates.Add(template); + ctx.SaveChanges(); + + var dto = await NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: template.Id), CancellationToken.None); + + dto.RequiresCompletionDocument.Should().BeTrue(); + dto.CompletionDocTemplate.Should().NotBeNull(); + dto.CompletionDocTemplate!.Id.Should().Be(template.Id); + dto.CompletionDocTemplate.Name.Should().Be("Completion Packet"); + } + + [Fact] + public async Task Create_WhenCompletionToggleOff_IgnoresTemplateLink() + { + using var ctx = NewContext(); + var template = Template("Completion Packet"); + ctx.CompletionDocTemplates.Add(template); + ctx.SaveChanges(); + + var dto = await NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: false, templateId: template.Id), CancellationToken.None); + + dto.RequiresCompletionDocument.Should().BeFalse(); + dto.CompletionDocTemplate.Should().BeNull(); + ctx.Services.Single().CompletionDocTemplateId.Should().BeNull(); + } + + [Theory] + [InlineData(WorkOrderType.PO)] + [InlineData(WorkOrderType.Project)] + [InlineData(WorkOrderType.Inspection)] + [InlineData(WorkOrderType.AddOn)] + [InlineData(WorkOrderType.Other)] + public async Task Create_WhenUnsupportedWorkOrderType_ThrowsWorkOrderTypeInvalid(WorkOrderType unsupported) + { + using var ctx = NewContext(); + var input = Input(types: new List { WorkOrderType.PM, unsupported }); + var act = () => NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("WorkOrderTypeInvalid"); + } + + [Fact] + public async Task Update_RewritesFieldsButPreservesActiveState() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + var updated = await service.UpdateAsync( + Admin(), created.Id, Input("Leak Repair", trade: "HVAC", iconKey: "hvac"), CancellationToken.None); + + updated.Name.Should().Be("Leak Repair"); + updated.Trade.Should().Be("HVAC"); + updated.IconKey.Should().Be("hvac"); + updated.IsActive.Should().BeFalse("update preserves the persisted active state"); + } + + [Fact] + public async Task Update_WhenAdminRequestsActiveState_ReactivatesService() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None); + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + var input = Input("Inactive service"); + input.IsActive = true; + var updated = await service.UpdateAsync(Admin(), created.Id, input, CancellationToken.None); + + updated.IsActive.Should().BeTrue(); + ctx.Services.Single().IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Update_WhenSchedulerRequestsActiveState_ThrowsForbidden() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None); + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + var input = Input("Inactive service"); + input.IsActive = true; + var act = () => service.UpdateAsync(Scheduler(), created.Id, input, CancellationToken.None); + + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + ctx.Services.Single().IsActive.Should().BeFalse(); + } + + [Fact] + public async Task Update_WhenNotFound_ThrowsNotFound() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).UpdateAsync(Admin(), 404, Input(), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task Update_WhenDuplicateNameElsewhere_ThrowsDuplicateName() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + await service.CreateAsync(Admin(), Input("Clog"), CancellationToken.None); + + var act = () => service.UpdateAsync(Admin(), first.Id, Input("clog"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("DuplicateName"); + } + + [Fact] + public async Task Update_WhenCallerLacksRole_ThrowsForbidden_BeforeAnyMutation() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + var act = () => service.UpdateAsync(Principal("User"), created.Id, Input("Changed"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + ctx.Services.Single().Name.Should().Be("Leak"); + } + + [Fact] + public async Task Deactivate_SoftStateChangeOnly_PreservesRow() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + ctx.Services.Should().HaveCount(1, "deactivation never hard deletes"); + var byId = await service.GetByIdAsync(created.Id, CancellationToken.None); + byId.Should().NotBeNull(); + byId!.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task Deactivate_WhenScheduler_ThrowsForbidden() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + var act = () => service.DeactivateAsync(Scheduler(), created.Id, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + ctx.Services.Single().IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Deactivate_WhenNotFound_ThrowsNotFound() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).DeactivateAsync(Admin(), 404, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task GetAll_FiltersByActiveStateAndWorkOrderType() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var pm = await service.CreateAsync(Admin(), Input("PM Service", types: new List { WorkOrderType.PM }), CancellationToken.None); + var reactive = await service.CreateAsync(Admin(), Input(name: "Reactive Service", trade: "HVAC", iconKey: "hvac", types: new List { WorkOrderType.Reactive }), CancellationToken.None); + await service.DeactivateAsync(Admin(), reactive.Id, CancellationToken.None); + + (await service.GetAllAsync(true, null, CancellationToken.None)).Select(s => s.Id) + .Should().BeEquivalentTo(new[] { pm.Id }); + (await service.GetAllAsync(false, null, CancellationToken.None)).Select(s => s.Id) + .Should().BeEquivalentTo(new[] { reactive.Id }); + + (await service.GetAllAsync(null, WorkOrderType.PM, CancellationToken.None)).Select(s => s.Id) + .Should().BeEquivalentTo(new[] { pm.Id }); + (await service.GetAllAsync(null, WorkOrderType.Emergency, CancellationToken.None)) + .Should().BeEmpty(); + } + + [Fact] + public async Task GetAll_WhenWorkOrderTypeFilterUnsupported_ThrowsWorkOrderTypeInvalid() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).GetAllAsync(null, WorkOrderType.Project, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("WorkOrderTypeInvalid"); + } + + [Fact] + public async Task GetById_WhenMissing_ReturnsNull() + { + using var ctx = NewContext(); + (await NewService(ctx).GetByIdAsync(99, CancellationToken.None)).Should().BeNull(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs b/Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs new file mode 100644 index 0000000..1706b5a --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs @@ -0,0 +1,138 @@ +using FluentAssertions; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class SessionStampServiceTests +{ + private const string UserId = "user-1"; + + private readonly Mock _users = new(); + private readonly SteppedTimeProvider _time = new(); + private readonly InMemorySessionStampCache _cache; + + public SessionStampServiceTests() + { + _cache = new InMemorySessionStampCache(_time); + } + + private SessionStampService NewService(string secret = "0123456789abcdef0123456789abcdef0123456789abcdef") => + new(_users.Object, _cache, Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = secret })); + + private void StoredStamp(string? stamp) => + _users.Setup(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny())).ReturnsAsync(stamp); + + [Fact] + public void The_token_carries_a_keyed_hash_never_the_stamp_itself() + { + var value = NewService().ClaimValueFor("STAMP-ONE"); + + value.Should().NotContain("STAMP-ONE"); + value.Should().Be(NewService().ClaimValueFor("STAMP-ONE")); + value.Should().NotBe(NewService().ClaimValueFor("STAMP-TWO")); + value.Should().NotBe(NewService(new string('z', 64)).ClaimValueFor("STAMP-ONE")); + } + + [Fact] + public async Task A_matching_stamp_is_read_once_per_cache_lifetime() + { + StoredStamp("STAMP-ONE"); + var service = NewService(); + var issued = service.ClaimValueFor("STAMP-ONE"); + + (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue(); + (await NewService().IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue(); + + _users.Verify(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny()), Times.Once); + } + + [Fact] + public async Task A_stamp_changed_by_another_instance_is_enforced_once_the_cached_value_expires() + { + StoredStamp("STAMP-ONE"); + var service = NewService(); + var issued = service.ClaimValueFor("STAMP-ONE"); + (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue(); + + StoredStamp("STAMP-TWO"); + _time.Advance(InMemorySessionStampCache.Lifetime - TimeSpan.FromSeconds(1)); + (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue(); + + _time.Advance(TimeSpan.FromSeconds(1)); + (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse(); + } + + [Fact] + public async Task A_stamp_changed_by_this_instance_is_enforced_at_once() + { + StoredStamp("STAMP-ONE"); + var service = NewService(); + var issued = service.ClaimValueFor("STAMP-ONE"); + (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue(); + + StoredStamp("STAMP-TWO"); + service.Forget(UserId); + + (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse(); + } + + [Fact] + public async Task A_read_that_races_a_change_is_not_cached() + { + var service = NewService(); + var issued = service.ClaimValueFor("STAMP-ONE"); + var reads = 0; + _users.Setup(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny())) + .ReturnsAsync(() => + { + // The first read returns the old stamp while this instance saves a new one. + if (reads++ == 0) + { + service.Forget(UserId); + return "STAMP-ONE"; + } + + return "STAMP-TWO"; + }); + + (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue(); + (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task A_missing_deleted_or_stampless_account_matches_nothing(string? stored) + { + StoredStamp(stored); + var service = NewService(); + + (await service.IsCurrentAsync(UserId, service.ClaimValueFor("STAMP-ONE"), CancellationToken.None)).Should().BeFalse(); + (await service.IsCurrentAsync(UserId, "", CancellationToken.None)).Should().BeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task A_token_without_a_stamp_is_refused_without_a_lookup(string? claimValue) + { + StoredStamp("STAMP-ONE"); + + (await NewService().IsCurrentAsync(UserId, claimValue, CancellationToken.None)).Should().BeFalse(); + + _users.Verify(users => users.GetActiveSecurityStampAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private sealed class SteppedTimeProvider : TimeProvider + { + private DateTimeOffset _now = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now = _now.Add(by); + } +} diff --git a/Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs b/Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs new file mode 100644 index 0000000..c5a718f --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs @@ -0,0 +1,503 @@ +using System.Security.Claims; +using System.Text.Json; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Moq; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// Site registry rules: tenant-scoped unique site codes, immutable codes, +/// permission-gated tombstone delete, open work order lookup and the +/// contact/notes write used by the work-order Site dialog. +/// +public class SiteRegistryServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static LocationService NewService(ApplicationDbContext ctx, string? role = "Admin") => + NewService(new LocationDataService(ctx), new AccountDataService(ctx), role); + + private static LocationService NewService( + ILocationDataService data, + IAccountDataService accounts, + string? role = "Admin") + { + var permissions = new Mock(); + permissions + .Setup(p => p.GetUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string userId, CancellationToken _) => role == null + ? null + : new TeamPermissionUserData { UserId = userId, RoleName = role }); + + return new LocationService( + data, + accounts, + new CreateLocationValidation(), + new UpdateLocationValidation(), + permissions.Object, + new TeamPermissionPolicy()); + } + + private static ClaimsPrincipal OrgWide(string role = "Admin") => Principal(role, new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)); + + private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin") => + Principal(role, new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString())); + + private static ClaimsPrincipal Principal(string role, Claim scope) => + new(new ClaimsIdentity(new List + { + new(ClaimTypes.NameIdentifier, "actor-1"), + new(ClaimTypes.Role, role), + scope + }, "test")); + + private static void SeedAccounts(ApplicationDbContext ctx, params int[] ids) + { + foreach (var id in ids) + ctx.Accounts.Add(new Accounts { Id = id, Name = $"Client {id}", IsDeleted = false }); + ctx.SaveChanges(); + } + + private static Locations SeedSite(ApplicationDbContext ctx, string code, int? accountId, bool deleted = false) + { + var site = new Locations { Name = code, AccountId = accountId, City = "Dallas", State = "TX", IsDeleted = deleted ? true : null }; + ctx.Locations.Add(site); + ctx.SaveChanges(); + return site; + } + + private static WorkOrder Wo( + int id, + int? locationId, + LifecycleStatus? status = LifecycleStatus.Incomplete, + int? accountId = 1, + string? siteCode = null, + string? legacyStatus = null, + bool deleted = false) => new() + { + Id = id, + LocationId = locationId, + LifecycleStatus = status, + LegacyStatus = legacyStatus, + AccountId = accountId, + SiteCode = siteCode, + IsDeleted = deleted ? true : null + }; + + private static LocationCreateRequestDTO CreateRequest(string code, int? accountId) => new() + { + Name = code, + AccountId = accountId, + Address = "1 Depot Rd", + City = "Dallas", + State = "TX", + Contacts = new List { new() { Name = "Main", Phone = "555-0100" } } + }; + + [Fact] + public async Task Create_DuplicateSiteCodeInSameClient_IsRejectedCaseInsensitively() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + SeedSite(ctx, "BK5", 1); + + var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" bk5 ", 1), OrgWide(), CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.Should().ContainSingle(); + } + + [Fact] + public async Task Create_SameSiteCodeForAnotherClient_IsAllowed() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1, 2); + SeedSite(ctx, "BK5", 1); + + await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 2), OrgWide(), CancellationToken.None); + + ctx.Locations.Where(l => l.Name == "BK5").Select(l => l.AccountId).Should().BeEquivalentTo(new int?[] { 1, 2 }); + } + + [Fact] + public async Task Create_SiteCodeOfADeletedSite_CanBeReused() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + SeedSite(ctx, "BK5", 1, deleted: true); + + await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), CancellationToken.None); + + ctx.Locations.Count(l => l.Name == "BK5" && l.IsDeleted != true).Should().Be(1); + } + + [Fact] + public async Task Create_BlankSiteCode_IsAValidationError() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + + var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" ", 1), OrgWide(), CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code is required."); + } + + [Fact] + public async Task Create_StoresTrimmedCodeNotesAndSitePhoneIndependentOfContacts() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + var request = CreateRequest(" DFW8 ", 1); + request.Phone = "555-9000"; + request.Notes = " Gate code 4411 "; + + await NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None); + + var site = ctx.Locations.Single(); + site.Name.Should().Be("DFW8"); + site.PhoneNumber.Should().Be("555-9000"); + site.Notes.Should().Be("Gate code 4411"); + } + + [Fact] + public async Task Update_ChangingAnExistingSiteCode_IsRejected() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", null); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync( + site.Id, new LocationUpdateRequestDTO { Name = "BK6" }, OrgWide(), CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code cannot be changed."); + ctx.Locations.AsNoTracking().Single().Name.Should().Be("BK5"); + } + + [Fact] + public async Task Update_KeepsCodeAndNotesWhenTheRequestOmitsThem() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", null); + site.Notes = "Dock 3"; + site.Status = "Active"; + ctx.SaveChanges(); + + await NewService(ctx).UpdateLocationFromRequestAsync( + site.Id, new LocationUpdateRequestDTO { Name = "bk5", City = "Memphis" }, OrgWide(), CancellationToken.None); + + var saved = ctx.Locations.AsNoTracking().Single(); + saved.Name.Should().Be("BK5"); + saved.City.Should().Be("Memphis"); + saved.Notes.Should().Be("Dock 3"); + saved.Status.Should().Be("Active"); + } + + [Fact] + public async Task Update_BlankLegacyCode_CanBeFilledOnceButMustBeUniqueInTheClient() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + SeedSite(ctx, "BK5", 1); + var legacy = SeedSite(ctx, "", 1); + + var duplicate = () => NewService(ctx).UpdateLocationFromRequestAsync( + legacy.Id, new LocationUpdateRequestDTO { Name = "bk5" }, OrgWide(), CancellationToken.None); + await duplicate.Should().ThrowAsync(); + + await NewService(ctx).UpdateLocationFromRequestAsync( + legacy.Id, new LocationUpdateRequestDTO { Name = "MEM1" }, OrgWide(), CancellationToken.None); + ctx.Locations.AsNoTracking().Single(l => l.Id == legacy.Id).Name.Should().Be("MEM1"); + } + + [Theory] + [InlineData("Admin")] + [InlineData("Scheduler")] + public async Task Delete_AdminOrScheduler_TombstonesTheSiteAndLeavesWorkOrdersAsTheyWere(string role) + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + var site = SeedSite(ctx, "BK5", 1); + ctx.Contacts.Add(new Contacts { LocationId = site.Id, FirstName = "Main", PhoneNumber = "555-0100" }); + ctx.workOrders.Add(Wo(10, site.Id)); + ctx.SaveChanges(); + + var deleted = await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None); + + deleted.Should().BeTrue(); + var tombstone = ctx.Locations.AsNoTracking().Single(); + tombstone.IsDeleted.Should().BeTrue(); + tombstone.DeleterUserId.Should().Be("actor-1"); + ctx.workOrders.AsNoTracking().Single().LocationId.Should().Be(site.Id, "work orders keep their site reference"); + ctx.Contacts.AsNoTracking().Single().IsDeleted.Should().NotBe(true, "contacts still back open work orders"); + + var data = new LocationDataService(ctx); + (await data.GetDetailByIdAsync(site.Id, CancellationToken.None)).Should().BeNull(); + (await data.GetSiteOptionsAsync(null, CancellationToken.None)).Should().BeEmpty(); + (await data.GetListPagedAsync(1, 10, null, null, CancellationToken.None)).TotalCount.Should().Be(0); + (await data.GetAccountScopeAsync(site.Id, CancellationToken.None)).Exists.Should().BeFalse("a deleted site cannot be assigned to new work orders"); + (await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None)).Should().BeFalse(); + } + + [Theory] + [InlineData("Dispatcher")] + [InlineData(null)] + public async Task Delete_WithoutDeleteSitesPermission_IsForbiddenAndKeepsTheSite(string? role) + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", null); + + var act = () => NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role ?? "Dispatcher"), CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true); + } + + [Fact] + public async Task Delete_SiteOfAnotherClient_IsRejectedForAnAccountScopedCaller() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", 2); + + var act = () => NewService(ctx).DeleteLocationByIdAsync(site.Id, AccountUser(1), CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true); + } + + [Fact] + public async Task OpenWorkOrders_ExcludeTerminalDeletedAndOtherSitesWork() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", 1); + var other = SeedSite(ctx, "MEM1", 1); + ctx.workOrders.AddRange( + Wo(1, site.Id), + Wo(2, site.Id, LifecycleStatus.Scheduled), + Wo(3, site.Id, LifecycleStatus.Completed), + Wo(4, site.Id, LifecycleStatus.Canceled), + Wo(5, site.Id, deleted: true), + Wo(6, site.Id, status: null, legacyStatus: "Completed"), + Wo(7, site.Id, status: null, legacyStatus: "Open"), + Wo(8, null, siteCode: "bk5"), + Wo(9, null, siteCode: "BK5", accountId: 2), + Wo(10, other.Id)); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None); + + result!.WorkOrderIds.Should().Equal(1, 2, 7, 8); + result.Count.Should().Be(4); + } + + [Fact] + public async Task OpenWorkOrders_AccountScopedCaller_SeesOnlyTheirClientsWork() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", 1); + ctx.workOrders.AddRange(Wo(1, site.Id, accountId: 1), Wo(2, site.Id, accountId: 2)); + ctx.SaveChanges(); + + var own = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(1), CancellationToken.None); + own!.WorkOrderIds.Should().Equal(1); + + var foreign = () => NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(2), CancellationToken.None); + await foreign.Should().ThrowAsync(); + } + + [Fact] + public async Task OpenWorkOrders_ReturnFullCountButCapIds() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", 1); + ctx.workOrders.AddRange(Enumerable.Range(1, LocationService.MaxOpenWorkOrderIds + 5).Select(id => Wo(id, site.Id))); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None); + + result!.Count.Should().Be(LocationService.MaxOpenWorkOrderIds + 5); + result.WorkOrderIds.Should().HaveCount(LocationService.MaxOpenWorkOrderIds); + } + + [Fact] + public async Task OpenWorkOrders_MissingOrDeletedSite_ReturnsNull() + { + using var ctx = NewContext(); + var deleted = SeedSite(ctx, "BK5", 1, deleted: true); + + (await NewService(ctx).GetOpenWorkOrdersAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeNull(); + (await NewService(ctx).GetOpenWorkOrdersAsync(999, OrgWide(), CancellationToken.None)).Should().BeNull(); + } + + [Fact] + public async Task UpdateSiteContactInfo_SavesContactsAndNotesToTheSiteRecord() + { + using var ctx = NewContext(); + var site = SeedSite(ctx, "BK5", null); + var main = new Contacts { LocationId = site.Id, FirstName = "Old Main", PhoneNumber = "555-0100", SiteContactOrder = 0 }; + ctx.Contacts.Add(main); + ctx.SaveChanges(); + + await NewService(ctx).UpdateSiteContactInfoAsync(site.Id, new SiteContactInfoRequestDTO + { + Contacts = new List + { + new() { Id = main.Id, Name = "New Main", Phone = "555-0199" }, + new() { Name = "Night Shift", Phone = "555-0200" } + }, + Notes = " Call ahead " + }, OrgWide("Dispatcher"), CancellationToken.None); + + var saved = ctx.Locations.AsNoTracking().Include(l => l.Contacts).Single(); + saved.Notes.Should().Be("Call ahead"); + saved.Contacts!.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder) + .Select(c => (c.Id == main.Id, c.FirstName, c.PhoneNumber)) + .Should().Equal((true, "New Main", "555-0199"), (false, "Night Shift", "555-0200")); + } + + [Fact] + public async Task UpdateSiteContactInfo_RejectsOutOfScopeDeletedAndContactlessRequests() + { + using var ctx = NewContext(); + var foreign = SeedSite(ctx, "BK5", 2); + var deleted = SeedSite(ctx, "MEM1", 1, deleted: true); + var request = new SiteContactInfoRequestDTO + { + Contacts = new List { new() { Name = "A", Phone = "1" } } + }; + + await ((Func)(() => NewService(ctx).UpdateSiteContactInfoAsync(foreign.Id, request, AccountUser(1), CancellationToken.None))) + .Should().ThrowAsync(); + await ((Func)(() => NewService(ctx).UpdateSiteContactInfoAsync(deleted.Id, request, OrgWide(), CancellationToken.None))) + .Should().ThrowAsync(); + await ((Func)(() => NewService(ctx).UpdateSiteContactInfoAsync( + foreign.Id, new SiteContactInfoRequestDTO { Notes = "x" }, OrgWide(), CancellationToken.None))) + .Should().ThrowAsync(); + } + + [Fact] + public async Task NewSiteOperations_ForwardTheCallersCancellationToken() + { + using var cts = new CancellationTokenSource(); + var token = cts.Token; + var site = new Locations { Id = 5, Name = "BK5", AccountId = 1, Contacts = new List() }; + var data = new Mock(); + data.Setup(d => d.GetByIdForUpdateAsync(5, token)).ReturnsAsync(site); + data.Setup(d => d.GetDetailByIdAsync(5, token)).ReturnsAsync(site); + data.Setup(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token)) + .ReturnsAsync((1, new[] { 7 })); + var service = NewService(data.Object, Mock.Of()); + + await service.GetOpenWorkOrdersAsync(5, OrgWide(), token); + await service.UpdateSiteContactInfoAsync(5, new SiteContactInfoRequestDTO + { + Contacts = new List { new() { Name = "A", Phone = "1" } } + }, OrgWide(), token); + await service.DeleteLocationByIdAsync(5, OrgWide(), token); + + data.Verify(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token), Times.Once); + data.Verify(d => d.UpdateAsync(site, token), Times.Exactly(2)); + } + + [Fact] + public async Task Create_DuplicateCheck_ForwardsTheCallersCancellationToken() + { + using var cts = new CancellationTokenSource(); + var token = cts.Token; + var data = new Mock(); + data.Setup(d => d.SiteCodeExistsAsync("BK5", 1, null, token)).ReturnsAsync(true); + var accounts = new Mock(); + accounts.Setup(a => a.ExistsActiveAsync(1, token)).ReturnsAsync(true); + var service = NewService(data.Object, accounts.Object); + + var act = () => service.CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), token); + + await act.Should().ThrowAsync(); + data.Verify(d => d.SiteCodeExistsAsync("BK5", 1, null, token), Times.Once); + } + + [Fact] + public void CompletionSnapshot_FreezesSiteNotesWhenTheWorkOrderHasNone() + { + var workOrder = new WorkOrder + { + Id = 1, + Locations = new Locations { Id = 5, Name = "BK5", Notes = "Gate code 4411", Contacts = new List() } + }; + + WorkOrderCompletionSnapshotMapper.Capture(workOrder); + + using var frozen = JsonDocument.Parse(workOrder.FrozenPoc!); + frozen.RootElement.GetProperty("notes").GetString().Should().Be("Gate code 4411"); + } + + public static TheoryData, string> MissingSiteFields => new() + { + { "no client", r => r.AccountId = null, "Client is required." }, + { "no street address", r => r.Address = " ", "Street Address is required." }, + { "no city", r => r.City = null, "City is required." }, + { "no state", r => r.State = "", "State is required." }, + { "no contacts list", r => r.Contacts = null, "At least one contact is required." }, + { "empty contacts list", r => r.Contacts = new List(), "At least one contact is required." }, + { "contact without phone", r => r.Contacts = new List { new() { Name = "Main", Phone = " " } }, "Contact phone is required." } + }; + + [Theory] + [MemberData(nameof(MissingSiteFields))] + public async Task Create_WithoutARequiredSiteField_IsAValidationErrorAndStoresNothing( + string _, + Action strip, + string expectedMessage) + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + var request = CreateRequest("BK9", 1); + strip(request); + + var act = () => NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(e => e.ErrorMessage == expectedMessage); + ctx.Locations.Should().BeEmpty(); + } + + [Fact] + public async Task Delete_RemovesTheSiteFromEveryLegacyRead() + { + using var ctx = NewContext(); + SeedAccounts(ctx, 1); + var deleted = SeedSite(ctx, "BK5", 1); + var kept = SeedSite(ctx, "BK6", 1); + var service = NewService(ctx); + + (await service.DeleteLocationByIdAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeTrue(); + + (await service.GetLocationByIdAsync(deleted.Id)).Should().BeNull(); + (await service.GetLocationByIdWithDetailsAsync(deleted.Id)).Should().BeNull(); + (await service.LocationExistsAsync(deleted.Id)).Should().BeFalse(); + (await service.GetTotalLocationCountAsync()).Should().Be(1); + (await service.GetAllLocationsAsync()).Select(l => l.Id).Should().Equal(kept.Id); + (await service.GetLocationsByAccountIdAsync(1)).Select(l => l.Id).Should().Equal(kept.Id); + (await service.GetLocationsPagedAsync(1, 10)).Items.Select(l => l.Id).Should().Equal(kept.Id); + (await new LocationDataService(ctx).GetAddressbookPagedAsync(1, 10)).TotalCount.Should().Be(1); + (await new VendorOperationsDataService(ctx, Mock.Of()).LocationExistsAsync(deleted.Id, CancellationToken.None)).Should().BeFalse(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs new file mode 100644 index 0000000..d032105 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs @@ -0,0 +1,171 @@ +using System.Reflection; +using System.Text.Json; +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.Filters; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging.Abstractions; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class TeamMemberInviteControllerTests +{ + [Fact] + public void RegistrationRequests_CarryNoUserIdentifier() + { + var requestTypes = new[] + { + typeof(TeamMemberInviteTokenRequestDTO), + typeof(VerifyTeamMemberInviteCodeRequestDTO), + typeof(CompleteTeamMemberRegistrationRequestDTO) + }; + + var properties = requestTypes + .SelectMany(type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + .Select(property => property.Name) + .Distinct() + .OrderBy(name => name); + + properties.Should().Equal("Code", "Password", "Phone", "Token"); + } + + [Fact] + public void RegistrationEndpoints_AreAnonymousAndNeverCached() + { + var type = typeof(TeamMemberInviteController); + + type.GetCustomAttribute().Should().NotBeNull(); + var cache = type.GetCustomAttribute(); + cache.Should().NotBeNull(); + cache!.NoStore.Should().BeTrue(); + } + + [Fact] + public void RegistrationEndpoints_UseTheControllerScopedExceptionFilter() + { + var filter = typeof(TeamMemberInviteController).GetCustomAttribute(); + + filter.Should().NotBeNull(); + filter!.ImplementationType.Should().Be(typeof(InviteRegistrationExceptionFilter)); + } + + [Fact] + public void ExceptionFilter_ReturnsAFixedBodyWithoutExceptionDetail() + { + var context = ExceptionContextFor(new ArgumentException( + "SELECT [Id] FROM [TeamMemberInvites] WHERE [TokenHash] = 'leak-me'")); + + new InviteRegistrationExceptionFilter(NullLogger.Instance) + .OnException(context); + + context.ExceptionHandled.Should().BeTrue(); + var result = context.Result.Should().BeOfType().Subject; + result.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + var body = JsonSerializer.Serialize(result.Value); + body.Should().Be( + "{\"code\":\"server_error\",\"message\":\"Something went wrong. Try again in a minute.\",\"retryAfterSeconds\":null}"); + body.Should().NotContain("SELECT").And.NotContain("leak-me"); + } + + [Fact] + public void ExceptionFilter_LeavesCancellationToTheHost() + { + var context = ExceptionContextFor(new OperationCanceledException()); + + new InviteRegistrationExceptionFilter(NullLogger.Instance) + .OnException(context); + + context.ExceptionHandled.Should().BeFalse(); + context.Result.Should().BeNull(); + } + + private static ExceptionContext ExceptionContextFor(Exception exception) + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + return new ExceptionContext(actionContext, new List()) { Exception = exception }; + } + + [Theory] + [InlineData(TeamMemberRegistrationStatus.InvalidInvite)] + [InlineData(TeamMemberRegistrationStatus.Ok)] + public async Task Complete_WithoutASession_ReturnsTheGenericInviteError(TeamMemberRegistrationStatus status) + { + var service = new Mock(); + service.Setup(x => x.CompleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new TeamMemberRegistrationOutcomeDTO { Status = status }); + var controller = new TeamMemberInviteController(service.Object) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var result = await controller.Complete(new CompleteTeamMemberRegistrationRequestDTO(), CancellationToken.None); + + var failure = result.Should().BeOfType().Subject; + failure.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + failure.Value.Should().BeEquivalentTo(new + { + code = "invalid_invite", + message = TeamMemberInviteController.InvalidInviteMessage + }); + } + + [Fact] + public void ResendInvite_RequiresAuthenticatedCaller() + { + typeof(TeamMemberController).GetCustomAttribute().Should().NotBeNull(); + typeof(TeamMemberController).GetMethod(nameof(TeamMemberController.ResendInvite))! + .GetCustomAttribute().Should().BeNull(); + } + + [Fact] + public async Task ResendInvite_Success_ReturnsInviteSent() + { + var invites = new Mock(); + invites.Setup(x => x.ResendAsync("user-1", It.IsAny(), It.IsAny())) + .ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = true }); + + var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None); + + result.Should().BeOfType().Which.Value.Should().BeEquivalentTo(new { message = "Invite sent" }); + } + + [Theory] + [InlineData("Forbidden", typeof(ForbidResult))] + [InlineData("Team member not found.", typeof(NotFoundObjectResult))] + [InlineData("Only pending team members can be re-invited.", typeof(BadRequestObjectResult))] + [InlineData("The invite could not be emailed. Try again.", typeof(BadRequestObjectResult))] + public async Task ResendInvite_Failure_MapsToHttpResult(string error, Type expected) + { + var invites = new Mock(); + invites.Setup(x => x.ResendAsync("user-1", It.IsAny(), It.IsAny())) + .ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = false, Error = error }); + + var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None); + + result.Should().BeOfType(expected); + } + + private static TeamMemberController NewTeamMemberController(Mock invites) + { + return new TeamMemberController(Mock.Of(), Mock.Of(), invites.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test")) + } + } + }; + } +} diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberPermissionsEndpointTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberPermissionsEndpointTests.cs new file mode 100644 index 0000000..83416b6 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamMemberPermissionsEndpointTests.cs @@ -0,0 +1,98 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using System.Text.Json; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class TeamMemberPermissionsEndpointTests +{ + [Fact] + public async Task Signed_in_caller_receives_their_effective_keys_as_a_permissions_array() + { + var data = new Mock(); + data.Setup(d => d.GetUserAsync("u1", It.IsAny())) + .ReturnsAsync(new TeamPermissionUserData + { + UserId = "u1", + RoleName = "Dispatcher", + Overrides = new Dictionary + { + [TeamPermissionKeys.CreateCompletionDocTemplates] = UserPermissionState.Allow + } + }); + var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }, "Bearer"))); + + var ok = Assert.IsType(await controller.GetMyPermissions(CancellationToken.None)); + + var json = JsonSerializer.Serialize(ok.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + using var document = JsonDocument.Parse(json); + var keys = document.RootElement.GetProperty("permissions").EnumerateArray() + .Select(element => element.GetString()).ToList(); + Assert.Contains(TeamPermissionKeys.CreateCompletionDocTemplates, keys); + Assert.DoesNotContain(TeamPermissionKeys.DeleteCompletionDocTemplates, keys); + } + + [Fact] + public async Task Unauthenticated_caller_gets_401_without_data_access() + { + var data = new Mock(MockBehavior.Strict); + var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity())); + + var result = Assert.IsType(await controller.GetMyPermissions(CancellationToken.None)); + + Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode); + } + + [Fact] + public void Route_is_get_me_permissions_and_requires_authentication() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMvcCore().AddApplicationPart(typeof(TeamMemberController).Assembly); + using var provider = services.BuildServiceProvider(); + var descriptor = provider + .GetRequiredService() + .ActionDescriptors.Items + .OfType() + .Single(d => d.ControllerTypeInfo == typeof(TeamMemberController) + && d.ActionName == nameof(TeamMemberController.GetMyPermissions)); + + var methods = descriptor.ActionConstraints!.OfType() + .SelectMany(c => c.HttpMethods); + Assert.Equal(new[] { "GET" }, methods); + Assert.Equal("api/team-members/me/permissions", descriptor.AttributeRouteInfo!.Template); + Assert.NotEmpty(typeof(TeamMemberController).GetCustomAttributes(typeof(AuthorizeAttribute), true)); + Assert.Empty(descriptor.MethodInfo.GetCustomAttributes(typeof(AllowAnonymousAttribute), true)); + } + + private static TeamMemberController Controller( + ITeamPermissionOverrideDataService data, + ClaimsPrincipal user) => + new( + Mock.Of(), + new TeamPermissionService(data, new TeamPermissionPolicy()), + Mock.Of()) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = user } + } + }; +} diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs new file mode 100644 index 0000000..3521716 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs @@ -0,0 +1,460 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class TeamMemberServiceTests +{ + [Fact] + public async Task Create_DispatcherRequiresAtLeastOneServiceArea() + { + var userManager = UserManager(); + var roleManager = RoleManager(); + var service = new TeamMemberService( + userManager.Object, + roleManager.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + + var result = await service.CreateAsync( + ValidRequest() with { ServiceAreas = Array.Empty() }, + Admin(), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("At least one service area is required for a Dispatcher."); + userManager.Verify(manager => manager.CreateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_RejectsUnknownColorAndPermission() + { + var service = NewService(out var userManager, out _, out _, out _, out _, out _); + + var badColor = await service.CreateAsync( + ValidRequest() with { Color = "#000000" }, + Admin(), + CancellationToken.None); + var badPermission = await service.CreateAsync( + ValidRequest() with + { + PermissionOverrides = new Dictionary + { + ["not-a-permission"] = UserPermissionState.Allow + } + }, + Admin(), + CancellationToken.None); + + badColor.Error.Should().Be("Color must be selected from the accessible palette."); + badPermission.Error.Should().Be("Unknown permission key: not-a-permission."); + userManager.Verify(manager => manager.CreateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_PersistsPendingUserWithoutPasswordAndStoresAreasAndOverrides() + { + var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _); + ApplicationUser? created = null; + userManager + .Setup(manager => manager.CreateAsync(It.IsAny())) + .Callback(user => + { + user.Id = "new-user"; + created = user; + }) + .ReturnsAsync(IdentityResult.Success); + userManager + .Setup(manager => manager.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((ApplicationUser?)null); + userManager + .Setup(manager => manager.AddToRoleAsync(It.IsAny(), "Dispatcher")) + .ReturnsAsync(IdentityResult.Success); + roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); + + var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None); + + result.Success.Should().BeTrue(); + created.Should().NotBeNull(); + created!.PasswordHash.Should().BeNull(); + created.EmailConfirmed.Should().BeFalse(); + created.PendingRegistration.Should().BeTrue(); + created.PhoneNumber.Should().Be("555-0100"); + result.Member!.ServiceAreas.Should().Equal("East", "West"); + areas.Verify(data => data.ReplaceAsync("new-user", It.Is>(value => value.SequenceEqual(new[] { "East", "West" })), It.IsAny()), Times.Once); + overrides.Verify(data => data.SetOverridesAsync("new-user", It.Is>(value => value["deleteSites"] == UserPermissionState.Allow), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Update_ProtectsAccountOwnerFromRoleChangeAndDeactivation() + { + var service = NewService( + out var userManager, + out _, + out _, + out _, + out var userData, + out _); + var owner = new ApplicationUser + { + Id = "owner", + FirstName = "Primary", + Email = "owner@example.com", + IsAccountOwner = true + }; + userManager.Setup(manager => manager.FindByIdAsync(owner.Id)).ReturnsAsync(owner); + userManager.Setup(manager => manager.GetRolesAsync(owner)).ReturnsAsync(new List { "Admin" }); + userData.Setup(data => data.IsAccountOwnerAsync(owner.Id, It.IsAny())).ReturnsAsync(true); + + var result = await service.UpdateAsync( + owner.Id, + new UpdateTeamMemberRequestDTO + { + Name = "Primary", + Role = "Dispatcher", + Color = "#F59E0B", + Email = owner.Email, + ServiceAreas = new[] { "East" }, + IsActive = false + }, + Admin(), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Forbidden"); + userData.Verify(data => data.UpdateUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Update_ReplacesAreasAndOverridesAndReturnsFullDetail() + { + var service = NewService( + out var userManager, + out var roleManager, + out var areas, + out var overrides, + out var userData, + out var permissions); + var user = new ApplicationUser + { + Id = "dispatcher", + FirstName = "Taylor", + Email = "old@example.com", + Color = "#F59E0B", + UniqueName = "Active" + }; + userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user); + userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List { "Dispatcher" }); + userManager.Setup(manager => manager.FindByEmailAsync("new@example.com")).ReturnsAsync((ApplicationUser?)null); + userManager.Setup(manager => manager.SetEmailAsync(user, "new@example.com")) + .Callback((member, email) => member.Email = email) + .ReturnsAsync(IdentityResult.Success); + userManager.Setup(manager => manager.SetUserNameAsync(user, "new@example.com")) + .Callback((member, email) => member.UserName = email) + .ReturnsAsync(IdentityResult.Success); + userManager.Setup(manager => manager.UpdateSecurityStampAsync(user)) + .ReturnsAsync(IdentityResult.Success); + roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); + areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny())) + .ReturnsAsync(new[] { "Central" }); + permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny(), It.IsAny())) + .ReturnsAsync(TeamPermissionResult.Success(new TeamPermissionProfileDTO + { + UserId = user.Id, + RoleName = "Dispatcher", + Permissions = new[] + { + new TeamPermissionValueDTO + { + PermissionKey = "createSites", + OverrideState = UserPermissionState.Deny, + IsGranted = false + } + } + })); + + var result = await service.UpdateAsync( + user.Id, + new UpdateTeamMemberRequestDTO + { + Name = "Taylor Updated", + Role = "Dispatcher", + Color = "#0D9488", + Email = "new@example.com", + Phone = "555-0101", + ServiceAreas = new[] { "West" }, + PermissionOverrides = new Dictionary + { + ["createSites"] = UserPermissionState.Deny + } + }, + Admin(), + CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Member!.Name.Should().Be("Taylor Updated"); + result.Member.Email.Should().Be("new@example.com"); + result.Member.ServiceAreas.Should().Equal("Central"); + result.Member.Permissions.Should().ContainSingle(permission => permission.PermissionKey == "createSites"); + userData.Verify(data => data.UpdateUserAsync(user, It.IsAny()), Times.Once); + userManager.Verify(manager => manager.UpdateSecurityStampAsync(user), Times.Once); + areas.Verify(data => data.ReplaceAsync(user.Id, It.Is>(value => value.SequenceEqual(new[] { "West" })), It.IsAny()), Times.Once); + overrides.Verify(data => data.ClearOverridesAsync(user.Id, It.IsAny()), Times.Once); + overrides.Verify(data => data.SetOverridesAsync(user.Id, It.Is>(value => value["createSites"] == UserPermissionState.Deny), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Update_SavesUnchangedNameWithoutDuplicatingRoleLikeLastName() + { + var service = NewService( + out var userManager, + out var roleManager, + out var areas, + out _, + out var userData, + out var permissions); + var user = new ApplicationUser + { + Id = "admin", + FirstName = "Legacy", + LastName = "Manager", + Email = "legacy@example.com", + Color = "#F59E0B", + UniqueName = "Active" + }; + userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user); + userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List { "Admin" }); + userManager.Setup(manager => manager.FindByEmailAsync(user.Email!)).ReturnsAsync(user); + roleManager.Setup(manager => manager.RoleExistsAsync("Admin")).ReturnsAsync(true); + areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny())) + .ReturnsAsync(Array.Empty()); + permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny(), It.IsAny())) + .ReturnsAsync(TeamPermissionResult.Success(new TeamPermissionProfileDTO + { + UserId = user.Id, + Permissions = Array.Empty() + })); + + var result = await service.UpdateAsync( + user.Id, + new UpdateTeamMemberRequestDTO + { + Name = "Legacy Manager", + Role = "Admin", + Color = "#F59E0B", + Email = user.Email + }, + Admin(), + CancellationToken.None); + + result.Success.Should().BeTrue(); + user.FirstName.Should().Be("Legacy"); + user.LastName.Should().Be("Manager"); + result.Member!.Name.Should().Be("Legacy Manager"); + userData.Verify(data => data.UpdateUserAsync(user, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Update_LeavesOmittedOverridesUnchanged() + { + var service = NewService( + out var userManager, + out var roleManager, + out var areas, + out var overrides, + out var userData, + out var permissions); + var user = new ApplicationUser + { + Id = "dispatcher", + FirstName = "Taylor", + Email = "taylor@example.com", + Color = "#F59E0B", + UniqueName = "Active" + }; + userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user); + userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List { "Dispatcher" }); + roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); + areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny())).ReturnsAsync(Array.Empty()); + permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny(), It.IsAny())) + .ReturnsAsync(TeamPermissionResult.Success(new TeamPermissionProfileDTO + { + UserId = user.Id, + Permissions = Array.Empty() + })); + + var result = await service.UpdateAsync( + user.Id, + new UpdateTeamMemberRequestDTO + { + Name = "Taylor", + Role = "Dispatcher", + Color = "#F59E0B", + Email = user.Email, + ServiceAreas = new[] { "East" } + }, + Admin(), + CancellationToken.None); + + result.Success.Should().BeTrue(); + overrides.Verify(data => data.ClearOverridesAsync(It.IsAny(), It.IsAny()), Times.Never); + overrides.Verify(data => data.SetOverridesAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + userData.Verify(data => data.UpdateUserAsync(user, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500() + { + var service = NewService(out var userManager, out var roleManager, out _, out _, out var userData, out _); + userManager + .Setup(manager => manager.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((ApplicationUser?)null); + roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); + userManager + .Setup(manager => manager.CreateAsync(It.IsAny())) + .ThrowsAsync(new DbUpdateException("duplicate key", new Exception())); + + var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Email is already in use."); + userData.Verify( + data => data.ExecuteTransactionalAsync(It.IsAny>(), It.IsAny()), + Times.Once); + userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny(), It.IsAny()), Times.Never); + userManager.Verify(manager => manager.DeleteAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_MidSequencePersistenceFailure_PropagatesWithoutCompensation() + { + var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _); + ApplicationUser? created = null; + userManager + .Setup(manager => manager.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((ApplicationUser?)null); + userManager + .Setup(manager => manager.CreateAsync(It.IsAny())) + .Callback(user => + { + user.Id = "mid-fail-user"; + created = user; + }) + .ReturnsAsync(IdentityResult.Success); + userManager + .Setup(manager => manager.AddToRoleAsync(It.IsAny(), "Dispatcher")) + .ReturnsAsync(IdentityResult.Success); + roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); + areas + .Setup(data => data.ReplaceAsync( + "mid-fail-user", + It.Is>(value => value.SequenceEqual(new[] { "East", "West" })), + It.IsAny())) + .Returns(Task.CompletedTask); + overrides + .Setup(data => data.SetOverridesAsync( + "mid-fail-user", + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("injected mid-sequence failure")); + + var failure = await Record.ExceptionAsync(() => service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None)); + + failure.Should().BeOfType() + .Which.Message.Should().Be("injected mid-sequence failure"); + created.Should().NotBeNull(); + areas.Verify( + data => data.ReplaceAsync("mid-fail-user", It.IsAny>(), It.IsAny()), + Times.Once); + userManager.Verify(manager => manager.DeleteAsync(It.IsAny()), Times.Never); + } + + private static TeamMemberService NewService( + out Mock> userManager, + out Mock> roleManager, + out Mock areas, + out Mock overrides, + out Mock userData, + out Mock permissions) + { + userManager = UserManager(); + roleManager = RoleManager(); + areas = new Mock(); + overrides = new Mock(); + userData = new Mock(); + permissions = new Mock(); + userData + .Setup(data => data.ExecuteTransactionalAsync( + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>((callback, token) => callback(token)); + return new TeamMemberService( + userManager.Object, + roleManager.Object, + areas.Object, + overrides.Object, + userData.Object, + permissions.Object, + Mock.Of(), + Mock.Of()); + } + + private static Mock> UserManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, + Microsoft.Extensions.Options.Options.Create(new IdentityOptions()), + Mock.Of>(), + Array.Empty>(), + Array.Empty>(), + Mock.Of(), + new IdentityErrorDescriber(), + Mock.Of(), + Mock.Of>>()); + } + + private static Mock> RoleManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, + Array.Empty>(), + Mock.Of(), + new IdentityErrorDescriber(), + Mock.Of>>()); + } + + private static CreateTeamMemberRequestDTO ValidRequest() => new() + { + Name = "Taylor Dispatcher", + Role = "dispatcher", + Color = "#F59E0B", + Email = "taylor@example.com", + Phone = "555-0100", + ServiceAreas = new[] { "east", "West" }, + PermissionOverrides = new Dictionary + { + ["deleteSites"] = UserPermissionState.Allow + } + }; + + private static ClaimsPrincipal Admin() => + new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test")); +} diff --git a/Api.SeaHavenIndustries.Tests/TeamPermissionOverrideDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamPermissionOverrideDataServiceTests.cs new file mode 100644 index 0000000..0f0861e --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamPermissionOverrideDataServiceTests.cs @@ -0,0 +1,80 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class TeamPermissionOverrideDataServiceTests +{ + [Fact] + public async Task GetUserAsync_ReturnsIdentityRoleAndStoredOverrides() + { + await using var context = NewContext(); + SeedUserWithRole(context, "u1", "Dispatcher"); + context.UserPermissionOverrides.Add(new UserPermissionOverride + { + UserId = "u1", + PermissionKey = "deleteSites", + State = UserPermissionState.Deny + }); + await context.SaveChangesAsync(); + + var result = await new TeamPermissionOverrideDataService(context) + .GetUserAsync("u1", CancellationToken.None); + + result.Should().NotBeNull(); + result!.RoleName.Should().Be("Dispatcher"); + result.Overrides["deleteSites"].Should().Be(UserPermissionState.Deny); + } + + [Fact] + public async Task SetOverrideAsync_UpsertsOneCompositeKey() + { + await using var context = NewContext(); + SeedUserWithRole(context, "u1", "Scheduler"); + await context.SaveChangesAsync(); + var service = new TeamPermissionOverrideDataService(context); + + await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Allow, CancellationToken.None); + await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Deny, CancellationToken.None); + + var rows = await context.UserPermissionOverrides.ToListAsync(); + rows.Should().ContainSingle(); + rows[0].State.Should().Be(UserPermissionState.Deny); + } + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static void SeedUserWithRole(ApplicationDbContext context, string userId, string roleName) + { + context.Users.Add(new ApplicationUser + { + Id = userId, + UserName = userId, + NormalizedUserName = userId.ToUpperInvariant(), + Email = userId + "@example.com", + NormalizedEmail = (userId + "@example.com").ToUpperInvariant() + }); + context.Roles.Add(new IdentityRole + { + Id = "role-1", + Name = roleName, + NormalizedName = roleName.ToUpperInvariant() + }); + context.UserRoles.Add(new IdentityUserRole + { + UserId = userId, + RoleId = "role-1" + }); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs new file mode 100644 index 0000000..7a5d6a7 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs @@ -0,0 +1,206 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +// Admin passes every permission check regardless of stored configuration. +// Uplift approval authority is otherwise driven by Approvals:Tier1Roles/Tier2Roles, +// which may be empty in a deployed environment; Admin must still be able to decide. +public sealed class UpliftAdminApprovalTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ClaimsPrincipal UserWithRoles(params string[] roles) + { + var claims = new List { new(ClaimTypes.NameIdentifier, "user-42") }; + claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + } + + private static UpliftService NewService(ApplicationDbContext context, ApprovalsOptions? options = null) => + new(new UpliftDataService(context), + new DispatchDataService(context), + new NoopDocumentStorage(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(options ?? new ApprovalsOptions())); + + private static async Task SeedPendingAsync(ApplicationDbContext context, int requiredTier) + { + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Completed", + NTEAmount = 1000m, + DispatchNumber = "DIS-1" + }; + context.Dispatches.Add(dispatch); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 1000m, + RequestedNTE = 1800m, + VendorReason = "reason", + Status = UpliftStatus.Pending, + RequiredTier = requiredTier, + CreatedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + return dispatch; + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + public void CanApprove_Admin_WithEmptyTierConfig_IsTrue(int tier) + { + using var context = NewContext(); + var service = NewService(context); + + service.CanApprove(UserWithRoles("Admin"), tier).Should().BeTrue(); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task List_Admin_WithEmptyTierConfig_CanDecidePendingRows(int tier) + { + using var context = NewContext(); + var dispatch = await SeedPendingAsync(context, tier); + var service = NewService(context); + + var queue = await service.ListAsync(UserWithRoles("Admin"), UpliftStatus.Pending, null, 1, 25, CancellationToken.None); + var forDispatch = await service.ListForDispatchAsync(UserWithRoles("Admin"), dispatch.Id, CancellationToken.None); + + queue.Items.Should().ContainSingle().Which.CanDecide.Should().BeTrue(); + forDispatch.Should().ContainSingle().Which.CanDecide.Should().BeTrue(); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task Approve_Admin_WithEmptyTierConfig_Succeeds(int tier) + { + using var context = NewContext(); + await SeedPendingAsync(context, tier); + var service = NewService(context); + + var result = await service.ApproveAsync(UserWithRoles("Admin"), 1, "ok", CancellationToken.None); + + result.Status.Should().Be(UpliftStatus.Approved); + context.Dispatches.Single().NTEAmount.Should().Be(1800m); + } + + [Fact] + public async Task Reject_Admin_WithEmptyTierConfig_Succeeds() + { + using var context = NewContext(); + await SeedPendingAsync(context, 2); + var service = NewService(context); + + var result = await service.RejectAsync(UserWithRoles("Admin"), 1, "no", CancellationToken.None); + + result.Status.Should().Be(UpliftStatus.Rejected); + context.Dispatches.Single().NTEAmount.Should().Be(1000m); + } + + [Fact] + public async Task RequestChanges_Admin_WithEmptyTierConfig_Succeeds() + { + using var context = NewContext(); + await SeedPendingAsync(context, 2); + var service = NewService(context); + + var result = await service.RequestChangesAsync(UserWithRoles("Admin"), 1, "more detail", CancellationToken.None); + + result.Status.Should().Be(UpliftStatus.ChangesRequested); + } + + [Fact] + public async Task EvidenceDownload_Admin_WithEmptyTierConfig_ReturnsFile() + { + using var context = NewContext(); + var dispatch = await SeedPendingAsync(context, 2); + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = dispatch.VendorId, + DispatchId = dispatch.Id, + WorkOrderId = dispatch.WorkOrderId!.Value, + OriginalFileName = "invoice.pdf", + StoredFileName = "evidence.bin", + ContentType = "application/pdf", + SizeBytes = 4, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = "UpliftEvidence", + Version = 1 + }); + context.DispatchUpliftRequests.Single().EvidenceDocumentId = 1; + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Admin"), 1, CancellationToken.None); + + result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok); + result.FileName.Should().Be("invoice.pdf"); + } + + [Theory] + [InlineData("Dispatcher")] + [InlineData("Scheduler")] + public async Task NonAdminRole_WithEmptyTierConfig_IsStillDenied(string role) + { + using var context = NewContext(); + await SeedPendingAsync(context, 1); + var service = NewService(context); + var user = UserWithRoles(role); + + service.CanApprove(user, 1).Should().BeFalse(); + service.CanApprove(user, 2).Should().BeFalse(); + var queue = await service.ListAsync(user, UpliftStatus.Pending, null, 1, 25, CancellationToken.None); + queue.Items.Should().ContainSingle().Which.CanDecide.Should().BeFalse(); + + var act = () => service.ApproveAsync(user, 1, "ok", CancellationToken.None); + + await act.Should().ThrowAsync(); + context.Dispatches.Single().NTEAmount.Should().Be(1000m); + context.DispatchUpliftRequests.Single().Status.Should().Be(UpliftStatus.Pending); + } + + [Fact] + public void Tier1OnlyRole_ApprovesTier1_ButNotTier2() + { + using var context = NewContext(); + var service = NewService(context, new ApprovalsOptions { Tier1Roles = new[] { "Approver" } }); + var user = UserWithRoles("Approver"); + + service.CanApprove(user, 1).Should().BeTrue(); + service.CanApprove(user, 2).Should().BeFalse(); + } + + private sealed class NoopDocumentStorage : IVendorDocumentStoragePort + { + public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) => Task.CompletedTask; + public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) => new MemoryStream(); + public void Delete(int vendorId, int dispatchId, string storedFileName) { } + } +} diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs index abad676..7b3f0ec 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -52,6 +52,70 @@ public class UpliftControllerTests envelope.Status.Should().Be("Success"); } + [Fact] + public async Task List_PassesStatusAndTierFiltersToService() + { + var service = new Mock(); + service.Setup(x => x.ListAsync(It.IsAny(), "Approved", 2, 3, 50, It.IsAny())) + .ReturnsAsync(new UpliftListResultDTO()); + + var controller = NewController(service); + + await controller.List("Approved", 2, 3, 50); + + service.Verify( + x => x.ListAsync(It.IsAny(), "Approved", 2, 3, 50, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task List_ReturnsQueueContractFieldsInEnvelope() + { + var service = new Mock(); + var item = new UpliftListItemDTO + { + Id = 7, + WorkOrderId = 11, + WorkOrderNumber = "WO-77", + WorkOrderSite = "SITE-EAST", + WorkOrderService = "HVAC", + WorkOrderClosed = true, + AttachmentCount = 2, + RequestedByName = "Gateway", + DecidedByName = "Grace Hopper", + WorkOrderAutoApprovedTotal = 150m, + WorkOrderAdminApprovedTotal = 300m, + WorkOrderApprovedExposureTotal = 450m + }; + service.Setup(x => x.ListAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new UpliftListResultDTO + { + Total = 1, + Page = 1, + PageSize = 25, + PendingExposureTotal = 200m, + Items = new[] { item } + }); + + var controller = NewController(service); + + var result = await controller.List(); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var data = envelope.Data as UpliftListResultDTO; + var returned = data!.Items.Should().ContainSingle().Subject; + returned.WorkOrderNumber.Should().Be("WO-77"); + returned.WorkOrderSite.Should().Be("SITE-EAST"); + returned.WorkOrderService.Should().Be("HVAC"); + returned.WorkOrderClosed.Should().BeTrue(); + returned.AttachmentCount.Should().Be(2); + returned.RequestedByName.Should().Be("Gateway"); + returned.DecidedByName.Should().Be("Grace Hopper"); + returned.WorkOrderApprovedExposureTotal.Should().Be(450m); + data!.PendingExposureTotal.Should().Be(200m); + } + [Fact] public async Task Approve_NotFound_Returns404() { @@ -67,6 +131,49 @@ public class UpliftControllerTests nf.Value.Should().BeOfType(); } + [Fact] + public async Task Revoke_WithReason_DelegatesToService() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + It.IsAny(), + 7, + "Policy change", + It.IsAny())) + .ReturnsAsync(new UpliftDecisionResultDTO { Id = 7, Status = "Revoked" }); + + var controller = NewController(service, "Admin"); + + var result = await controller.Revoke( + 7, + new UpliftController.DecisionRequest { Note = "Policy change" }); + + result.Should().BeOfType(); + service.Verify(x => x.RevokeAsync( + It.IsAny(), + 7, + "Policy change", + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Revoke_WithoutReason_ReturnsBadRequest() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + It.IsAny(), + 7, + string.Empty, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("reason required")); + + var controller = NewController(service, "Admin"); + + var result = await controller.Revoke(7, null); + + result.Should().BeOfType(); + } + [Fact] public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally() { diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs new file mode 100644 index 0000000..b5427f5 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -0,0 +1,1045 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +// Behavior tests for the approval queue read contract. These exercise the real +// service + data-service layers against an in-memory DbContext so that queue +// ordering, filters, flattened work-order fields, exposure aggregation, and +// per-tier read authorization are validated through the public contract. +public sealed class UpliftQueueReadTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ApprovalsOptions NewOptions() => new() + { + UpliftTier1MaxUsd = 2500m, + Tier1Roles = new[] { "Approver" }, + Tier2Roles = new[] { "Manager" }, + Tier1NotificationRecipients = new[] { "tier1@example.com" }, + Tier2NotificationRecipients = new[] { "tier2@example.com" }, + EscalationRecipients = new[] { "escalate@example.com" } + }; + + private static UpliftService NewService(ApplicationDbContext context) => + new(new UpliftDataService(context), + new DispatchDataService(context), + Mock.Of(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(NewOptions())); + + private static ClaimsPrincipal UserWithRoles(params string[] roles) + { + var claims = new List { new(ClaimTypes.NameIdentifier, "user-42") }; + claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + } + + private static DispatchUpliftRequest Request( + Dispatch dispatch, + string status, + DateTime created, + DateTime? decided = null, + int tier = 1, + decimal requested = 100m, + string? reason = null, + string? requestedBy = null, + string? createdBy = null, + decimal? currentNte = null) => new() + { + DispatchId = dispatch.Id, + Status = status, + CreatedDate = created, + DecidedAt = decided, + RequiredTier = tier, + RequestedNTE = requested, + CurrentNTE = currentNte, + VendorReason = reason, + RequestedByVendorName = requestedBy, + createdby = createdBy, + NotificationStatus = "Pending" + }; + + private static async Task<(Vendor Vendor, WorkOrder WorkOrder)> SeedWorkOrderAsync( + ApplicationDbContext context, + string number, + string site, + string service, + string vendorName = "Gateway") + { + var vendor = new Vendor { CompanyName = vendorName, IsActive = true }; + var workOrder = new WorkOrder + { + InternalWONumber = number, + WorkerOrderNumber = $"legacy-{number}", + SiteCode = site, + Service = service, + WorkerOrderTitle = "Repair" + }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + return (vendor, workOrder); + } + + private static async Task SeedDispatchAsync( + ApplicationDbContext context, Vendor vendor, WorkOrder workOrder, string number) + { + var dispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + DispatchNumber = number, + Status = "Completed", + NTEAmount = 1000m + }; + context.Dispatches.Add(dispatch); + await context.SaveChangesAsync(); + return dispatch; + } + + // --- Ordering --- + + [Fact] + public async Task List_PendingStatus_OrdersOldestRequestFirst() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 3)), + Request(dispatch, "Pending", new DateTime(2026, 3, 1)), + Request(dispatch, "Pending", new DateTime(2026, 3, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Select(i => i.RequestedAt).Should().Equal( + new DateTime(2026, 3, 1), + new DateTime(2026, 3, 2), + new DateTime(2026, 3, 3)); + } + + [Fact] + public async Task List_ApprovedStatus_OrdersMostRecentlyDecidedFirst() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)), + Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)), + Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + result.Items.Select(i => i.DecidedAt).Should().Equal( + new DateTime(2026, 3, 20), + new DateTime(2026, 3, 15), + new DateTime(2026, 3, 10)); + } + + [Fact] + public async Task List_RejectedStatus_OrdersMostRecentlyRejectedFirst() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + // Request order (3/1, 3/2, 3/3) deliberately disagrees with decision order. + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)), + Request(dispatch, "Rejected", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)), + Request(dispatch, "Rejected", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None); + + result.Items.Select(i => i.DecidedAt).Should().Equal( + new DateTime(2026, 3, 20), + new DateTime(2026, 3, 15), + new DateTime(2026, 3, 10)); + } + + [Fact] + public async Task List_RejectedStatus_ReturnsOnlyRejectedRequests_IncludingLegacyDenied() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 1)), + Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 3)), + Request(dispatch, "Revoked", new DateTime(2026, 3, 4), decided: new DateTime(2026, 3, 5)), + Request(dispatch, "Withdrawn", new DateTime(2026, 3, 6)), + Request(dispatch, "Rejected", new DateTime(2026, 3, 7), decided: new DateTime(2026, 3, 8)), + Request(dispatch, "Denied", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None); + + result.Total.Should().Be(2); + result.Items.Select(i => i.Status).Should().Equal("Rejected", "Rejected"); + result.Items.Select(i => i.DecidedAt).Should().Equal( + new DateTime(2026, 3, 8), + new DateTime(2026, 2, 2)); + } + + [Fact] + public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)), + Request(dispatch, "Approved", new DateTime(2026, 3, 10), decided: new DateTime(2026, 3, 11))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), null, null, 1, 25, CancellationToken.None); + + result.Items.Select(i => i.RequestedAt).Should().ContainInOrder( + new DateTime(2026, 3, 10), + new DateTime(2026, 3, 1)); + } + + // --- Filters and scoping --- + + [Fact] + public async Task List_StatusAndTierFilters_Combine() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1), + Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2), + Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), tier: 2)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", 2, 1, 25, CancellationToken.None); + + result.Total.Should().Be(1); + result.Items.Should().ContainSingle(i => i.RequiredTier == 2 && i.Status == "Pending"); + } + + [Fact] + public async Task List_ExcludesDeletedRequestsAndRequestsOnDeletedDispatches() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + var deletedDispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + DispatchNumber = "DIS-DELETED", + Status = "Completed", + IsDeleted = true + }; + context.Dispatches.Add(deletedDispatch); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 1)), + new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Pending", + CreatedDate = new DateTime(2026, 3, 2), + IsDeleted = true, + RequiredTier = 1, + RequestedNTE = 100m + }, + Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Total.Should().Be(1); + result.Items.Should().ContainSingle(i => i.RequestedAt == new DateTime(2026, 3, 1)); + } + + // --- Flattened queue fields --- + + [Fact] + public async Task List_MapsFlattenedWorkOrderAndRequesterFields() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(Request( + dispatch, "Pending", new DateTime(2026, 3, 2), + tier: 2, requested: 400m, reason: "Scope grew", + requestedBy: "Gateway", createdBy: "user-7", currentNte: 100m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderId.Should().Be(workOrder.Id); + item.WorkOrderNumber.Should().Be("WO-77"); + item.WorkOrderSite.Should().Be("SITE-EAST"); + item.WorkOrderService.Should().Be("HVAC"); + item.VendorCompanyName.Should().Be("Gateway"); + item.RequestedByName.Should().Be("Gateway"); + item.RequestedAt.Should().Be(new DateTime(2026, 3, 2)); + item.VendorReason.Should().Be("Scope grew"); + // A work-order request (createdby set) stores the increase itself in RequestedNTE. + item.Delta.Should().Be(400m); + item.Status.Should().Be("Pending"); + } + + [Fact] + public async Task List_MapsDetailModalWorkOrderContext_DispatcherTechnicianAndScheduledDate() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-81", "SITE-N", "Plumbing"); + vendor.ContactName = " Tom Tech "; + workOrder.ScheduledDate = new DateTime(2026, 4, 10, 9, 30, 0); + workOrder.AssignTo = "dispatcher-1"; + context.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Dana", LastName = "Ruiz" }); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-81"); + context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 4, 10, 9, 30, 0)); + item.WorkOrderDispatcherName.Should().Be("Dana Ruiz"); + item.TechnicianName.Should().Be("Tom Tech"); + item.VendorCompanyName.Should().Be("Gateway"); + } + + [Fact] + public async Task List_DetailModalWorkOrderContext_IsNullForUnassignedUnscheduledWorkOrder() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-82", "SITE-N", "Plumbing"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-82"); + context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderScheduledDate.Should().BeNull(); + item.WorkOrderDispatcherName.Should().BeNull(); + item.TechnicianName.Should().BeNull(); + } + + [Fact] + public async Task List_DetailModalWorkOrderContext_NeverCrossesAccounts() + { + // Two tenants' work orders sit side by side. Each queue row must carry the + // dispatcher, technician and schedule of its own work order and vendor only; + // another account's work order never supplies them. + using var context = NewContext(); + var accountA = new Accounts { Name = "Account A" }; + var accountB = new Accounts { Name = "Account B" }; + context.AddRange(accountA, accountB); + context.Users.AddRange( + new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" }, + new ApplicationUser { Id = "dispatcher-b", FirstName = "Ben", LastName = "Beta" }); + await context.SaveChangesAsync(); + + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Vendor A"); + vendorA.ContactName = "Tech A"; + workOrderA.AccountId = accountA.Id; + workOrderA.AssignTo = "dispatcher-a"; + workOrderA.ScheduledDate = new DateTime(2026, 5, 1); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Roofing", "Vendor B"); + vendorB.ContactName = "Tech B"; + workOrderB.AccountId = accountB.Id; + workOrderB.AssignTo = "dispatcher-b"; + workOrderB.ScheduledDate = new DateTime(2026, 6, 1); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + context.DispatchUpliftRequests.AddRange( + Request(dispatchA, "Pending", new DateTime(2026, 4, 1)), + Request(dispatchB, "Pending", new DateTime(2026, 4, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var rowA = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderA.Id).Subject; + rowA.WorkOrderDispatcherName.Should().Be("Ann Alpha"); + rowA.TechnicianName.Should().Be("Tech A"); + rowA.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 5, 1)); + var rowB = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderB.Id).Subject; + rowB.WorkOrderDispatcherName.Should().Be("Ben Beta"); + rowB.TechnicianName.Should().Be("Tech B"); + rowB.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 6, 1)); + } + + [Fact] + public async Task List_ResolvesWorkOrderContext_ForDispatchLinkedOnlyThroughDispatchWorkOrders() + { + // A dispatch can carry no primary WorkOrderId yet be linked to a work order + // through DispatchWorkOrders (the supported shape exercised by + // Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks). The queue + // read must resolve the same effective work order the sibling reads use, or the + // Approved tab surfaces blank WO context, workOrderClosed:false, and zero + // exposure on a terminal work order. + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-LINKED", "SITE-Z", "Electrical"); + workOrder.LifecycleStatus = LifecycleStatus.Completed; + var linkedDispatch = new Dispatch + { + VendorId = vendor.Id, + DispatchNumber = "DIS-LINKED", + Status = "Completed" + }; + context.Dispatches.Add(linkedDispatch); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = linkedDispatch.Id, + WorkOrderId = workOrder.Id + }); + context.DispatchUpliftRequests.Add(Request( + linkedDispatch, "Approved", new DateTime(2026, 3, 1), + decided: new DateTime(2026, 3, 2), requested: 90m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderId.Should().Be(workOrder.Id); + item.WorkOrderNumber.Should().Be("WO-LINKED"); + item.WorkOrderSite.Should().Be("SITE-Z"); + item.WorkOrderService.Should().Be("Electrical"); + item.WorkOrderClosed.Should().BeTrue(); + item.WorkOrderApprovedExposureTotal.Should().Be(90m); + } + + [Fact] + public async Task List_WorkOrderNumber_RendersInternalShNumber_NotTheCrmExternalId() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + // Synced work orders carry the CRM external id in WorkerOrderNumber; the SH + // display number the board renders is stamped on InternalWONumber. + var workOrder = new WorkOrder + { + InternalWONumber = "10000000001", + WorkerOrderNumber = "CRM-EXT-77", + SiteCode = "SITE-EAST", + Service = "HVAC", + WorkerOrderTitle = "Repair" + }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderNumber.Should().Be("10000000001"); + item.WorkOrderNumber.Should().NotBe("CRM-EXT-77"); + } + + [Fact] + public async Task List_RequesterLabelFallsBackToCreatingUserName() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(Request( + dispatch, "Pending", new DateTime(2026, 3, 2), + requestedBy: null, createdBy: "user-7")); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Single().RequestedByName.Should().Be("Ada Smith"); + } + + [Fact] + public async Task List_MapsEvidenceAttachmentSummary() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + var evidence = new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + OriginalFileName = "quote.pdf", + StoredFileName = "evidence.bin", + ContentType = "application/pdf", + SizeBytes = 512, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = "UpliftEvidence", + Version = 1 + }; + context.VendorCompletionDocuments.Add(evidence); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Pending", + CreatedDate = new DateTime(2026, 3, 2), + RequiredTier = 1, + RequestedNTE = 200m, + EvidenceDocumentId = evidence.Id + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Single(); + item.EvidenceDocumentId.Should().Be(evidence.Id); + item.EvidenceFileName.Should().Be("quote.pdf"); + item.EvidenceContentType.Should().Be("application/pdf"); + item.EvidenceSizeBytes.Should().Be(512); + } + + // --- Queue contract fields (SH-208/SH-213) --- + + [Fact] + public async Task List_MarksWorkOrderClosed_OnlyForTerminalLifecycle() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing"); + workOrderA.LifecycleStatus = LifecycleStatus.Completed; + workOrderB.LifecycleStatus = LifecycleStatus.Canceled; + await context.SaveChangesAsync(); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "Electrical"); + var dispatchC = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C"); + context.DispatchUpliftRequests.AddRange( + Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)), + Request(dispatchB, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4)), + Request(dispatchC, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Admin"), "Approved", null, 1, 25, CancellationToken.None); + + result.Items.Single(i => i.WorkOrderNumber == "WO-A").WorkOrderClosed.Should().BeTrue(); + result.Items.Single(i => i.WorkOrderNumber == "WO-B").WorkOrderClosed.Should().BeTrue(); + result.Items.Single(i => i.WorkOrderNumber == "WO-C").WorkOrderClosed.Should().BeFalse(); + } + + [Fact] + public async Task List_ApprovedRow_CarriesDecidedByNameFromDecidingUser() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); + context.Users.Add(new ApplicationUser { Id = "user-9", FirstName = "Grace", LastName = "Hopper" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Approved", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2), + DecidedByUserId = "user-9", + RequiredTier = 1, + RequestedNTE = 300m, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + result.Items.Single().DecidedByName.Should().Be("Grace Hopper"); + } + + [Fact] + public async Task List_RejectedRow_CarriesRejecterRequesterDecisionTimeAndReason() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-R", "SITE-R", "Plumbing"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-R"); + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Lovelace" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Rejected", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2, 16, 40, 0), + DecidedByUserId = "user-7", + DecisionNote = "Outside this work order's scope", + RequestedByVendorName = "Gateway", + RequiredTier = 1, + RequestedNTE = 250m, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None); + + var row = result.Items.Single(); + row.DecidedByName.Should().Be("Ada Lovelace"); + row.RequestedByName.Should().Be("Gateway"); + row.DecidedAt.Should().Be(new DateTime(2026, 3, 2, 16, 40, 0)); + row.DecisionNote.Should().Be("Outside this work order's scope"); + row.WorkOrderNumber.Should().Be("WO-R"); + } + + [Fact] + public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.AddRange( + // Vendor-portal rows (createdby null) store the requested new NTE total, + // so their increase is 400 - 100 = 300 and 600 - 250 = 350. + Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m), + Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m), + // Work-order rows (createdby set) store the increase itself: 90. + Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"), + // Non-pending rows never add pending exposure. + Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + // Page 1 with a single row still reports the total across ALL pending rows. + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 1, CancellationToken.None); + + result.Items.Should().HaveCount(1); + result.PendingExposureTotal.Should().Be(740m); + } + + [Fact] + public async Task List_PendingExposureTotal_EqualsSumOfPendingRowAmounts_ForBothCreationPaths() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "HVAC"); + var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "HVAC"); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + var deletedDispatch = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C"); + deletedDispatch.IsDeleted = true; + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.AddRange( + // Raised from the work order, which already carries a 3,000 NTE: RequestedNTE + // is the 5,000,000,000 increase itself. + Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 5_000_000_000m, currentNte: 3_000m, createdBy: "user-7"), + // Vendor-portal request for a 3,333 total on a 3,000 NTE: a 333 increase. + Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 3_333m, currentNte: 3_000m), + // A pending request on a deleted dispatch is not a queue row, so it adds nothing. + Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3), requested: 700m, currentNte: 100m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Should().HaveCount(2); + result.Items.Select(i => i.Delta).Should().BeEquivalentTo(new[] { 5_000_000_000m, 333m }); + result.PendingExposureTotal.Should().Be(5_000_000_333m); + result.PendingExposureTotal.Should().Be(result.Items.Sum(i => i.Delta)); + } + + // --- Approved-on-WO exposure totals --- + + [Fact] + public async Task List_AggregatesApprovedExposurePerWorkOrder_ExcludingNonApprovedStatuses() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Gateway-A"); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing", "Gateway-B"); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + context.DispatchUpliftRequests.AddRange( + // Vendor-portal row (createdby null): stores the requested new NTE total, + // so the granted amount is 400 - 100 = 300. + Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 400m, currentNte: 100m), + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "NoApprovalRequired", + CreatedDate = new DateTime(2026, 3, 1), + RequiredTier = 0, + RequestedNTE = 150m + }, + Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 999m), + Request(dispatchA, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 999m), + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "Withdrawn", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2), + RequiredTier = 1, + RequestedNTE = 999m + }, + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "Revoked", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2), + RequiredTier = 1, + RequestedNTE = 999m + }, + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "Expired", + CreatedDate = new DateTime(2026, 3, 1), + RequiredTier = 1, + RequestedNTE = 999m + }, + Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m, currentNte: 0m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + var itemA = result.Items.Single(i => i.WorkOrderNumber == "WO-A"); + itemA.WorkOrderAutoApprovedTotal.Should().Be(150m); + itemA.WorkOrderAdminApprovedTotal.Should().Be(300m); + itemA.WorkOrderApprovedExposureTotal.Should().Be(450m); + + var itemB = result.Items.Single(i => i.WorkOrderNumber == "WO-B"); + itemB.WorkOrderAutoApprovedTotal.Should().Be(0m); + itemB.WorkOrderAdminApprovedTotal.Should().Be(75m); + itemB.WorkOrderApprovedExposureTotal.Should().Be(75m); + } + + [Fact] + public async Task Exposure_UsesGrantedAmountForVendorPortalAndRequestedAmountForWorkOrderRequests() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-ORIGIN", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-ORIGIN"); + context.DispatchUpliftRequests.AddRange( + Request( + dispatch, + "Approved", + new DateTime(2026, 3, 1), + decided: new DateTime(2026, 3, 2), + requested: 400m, + currentNte: 100m), + Request( + dispatch, + "Approved", + new DateTime(2026, 3, 3), + decided: new DateTime(2026, 3, 4), + requested: 250m, + createdBy: "internal-user", + currentNte: 100m)); + await context.SaveChangesAsync(); + + var exposure = await new UpliftDataService(context) + .GetApprovedExposureForWorkOrdersAsync(new[] { workOrder.Id }, CancellationToken.None); + + exposure.Should().ContainSingle().Which.AdminApprovedTotal.Should().Be(550m); + } + + [Fact] + public async Task Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var linkedDispatch = new Dispatch + { + VendorId = vendorA.Id, + DispatchNumber = "DIS-LINKED", + Status = "Completed" + }; + context.Dispatches.Add(linkedDispatch); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = linkedDispatch.Id, + WorkOrderId = workOrderA.Id + }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(Request( + linkedDispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 60m)); + await context.SaveChangesAsync(); + var data = new UpliftDataService(context); + + var exposure = await data.GetApprovedExposureForWorkOrdersAsync( + new[] { workOrderA.Id }, CancellationToken.None); + + var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrderA.Id).Subject; + total.AdminApprovedTotal.Should().Be(60m); + total.AutoApprovedTotal.Should().Be(0m); + } + + [Fact] + public async Task Exposure_AdminApprovedSumsGrantedAmountsPerCreationPath() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); + context.DispatchUpliftRequests.AddRange( + // Vendor-portal rows (createdby null) store the requested new NTE total: + // 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300. + Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m), + Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m), + // Work-order-path rows (createdby set) store the granted increment: 200. + Request(dispatch, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7")); + await context.SaveChangesAsync(); + var data = new UpliftDataService(context); + + var exposure = await data.GetApprovedExposureForWorkOrdersAsync( + new[] { workOrder.Id }, CancellationToken.None); + + // Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would + // double-count whole NTE totals and report 3500. + var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject; + total.AdminApprovedTotal.Should().Be(1000m); + total.AutoApprovedTotal.Should().Be(0m); + } + + [Fact] + public async Task Exposure_AutoApprovedSumsGrantedAmountsPerCreationPath() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); + context.DispatchUpliftRequests.AddRange( + // Vendor-portal rows (createdby null) store the requested new NTE total: + // 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300. + Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m), + Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m), + // Work-order-path rows (createdby set) store the granted increment: 200. + Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7")); + await context.SaveChangesAsync(); + var data = new UpliftDataService(context); + + var exposure = await data.GetApprovedExposureForWorkOrdersAsync( + new[] { workOrder.Id }, CancellationToken.None); + + // Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would + // double-count whole NTE totals and report 3500. + var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject; + total.AutoApprovedTotal.Should().Be(1000m); + total.AdminApprovedTotal.Should().Be(0m); + } + + // --- Read authorization (tier roles) --- + + [Fact] + public async Task List_CanDecide_ReflectsTierRolesForTheCaller() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1), + Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var tier1Only = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + var tier2User = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None); + + tier1Only.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeTrue(); + tier1Only.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeFalse(); + tier2User.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeTrue(); + tier2User.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeFalse(); + } + + // --- Closed-work-order flag and decider (SH-208) --- + + [Fact] + public async Task List_MarksWorkOrderClosedOnlyForTerminalLifecycleStatuses() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-OPEN", "SITE-A", "HVAC"); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-DONE", "SITE-B", "HVAC"); + var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-CANCEL", "SITE-C", "HVAC"); + workOrderA.LifecycleStatus = LifecycleStatus.InProgress; + workOrderB.LifecycleStatus = LifecycleStatus.Completed; + workOrderC.LifecycleStatus = LifecycleStatus.Canceled; + await context.SaveChangesAsync(); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + var dispatchC = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C"); + context.DispatchUpliftRequests.AddRange( + Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)), + Request(dispatchB, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)), + Request(dispatchC, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + result.Items.Single(i => i.WorkOrderNumber == "WO-OPEN").WorkOrderClosed.Should().BeFalse(); + result.Items.Single(i => i.WorkOrderNumber == "WO-DONE").WorkOrderClosed.Should().BeTrue(); + result.Items.Single(i => i.WorkOrderNumber == "WO-CANCEL").WorkOrderClosed.Should().BeTrue(); + } + + [Fact] + public async Task List_MapsDecidedByNameFromTheDecidingUser() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.Users.Add(new ApplicationUser { Id = "user-9", FirstName = "Grace", LastName = "Hopper" }); + await context.SaveChangesAsync(); + var request = Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)); + request.DecidedByUserId = "user-9"; + context.DispatchUpliftRequests.Add(request); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + result.Items.Single().DecidedByName.Should().Be("Grace Hopper"); + } + + // --- Attachment count (SH-207) --- + + [Fact] + public async Task List_CountsOnlyNonDeletedUpliftEvidenceAttachments() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.VendorCompletionDocuments.AddRange( + Document(dispatch, vendor, workOrder, "quote.pdf"), + Document(dispatch, vendor, workOrder, "photo.jpg"), + Document(dispatch, vendor, workOrder, "signed-completion.pdf", purpose: "Completion"), + Document(dispatch, vendor, workOrder, "deleted-estimate.pdf", isDeleted: true)); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Pending", + CreatedDate = new DateTime(2026, 3, 2), + RequiredTier = 1, + RequestedNTE = 200m + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Single().AttachmentCount.Should().Be(2); + } + + private static VendorCompletionDocument Document( + Dispatch dispatch, + Vendor vendor, + WorkOrder workOrder, + string fileName, + string purpose = "UpliftEvidence", + bool isDeleted = false) => new() + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + OriginalFileName = fileName, + StoredFileName = $"{fileName}.bin", + ContentType = "application/octet-stream", + SizeBytes = 128, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = purpose, + Version = 1, + IsDeleted = isDeleted + }; + + // --- Queue-wide pending exposure (SH-207) --- + + [Fact] + public async Task List_PendingExposureTotal_SumsPendingRequestsAcrossTheQueueRegardlessOfFilter() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "HVAC"); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + context.DispatchUpliftRequests.AddRange( + Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 125m), + Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 75m), + Request(dispatchA, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 999m), + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "Pending", + CreatedDate = new DateTime(2026, 3, 5), + RequiredTier = 1, + RequestedNTE = 500m, + IsDeleted = true + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + // Even when the caller reads the Approved decision log, the header total + // still reflects the whole pending queue. + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + result.PendingExposureTotal.Should().Be(200m); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs b/Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs new file mode 100644 index 0000000..a8c9158 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs @@ -0,0 +1,278 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// Revoke rules exercised end to end through both revoke endpoints (the Uplift +/// Approvals route and the work-order route) against the real services, so a +/// refusal is observed as the HTTP result and the unchanged stored state. +/// +public sealed class UpliftRevokeEndpointRulesTests +{ + private const int WorkOrderId = 1; + private const int DispatchId = 10; + private const int AutoApprovedId = 100; + private const int AdminApprovedId = 101; + + public enum RevokeRoute + { + UpliftApprovals, + WorkOrder, + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ClaimsPrincipal OrgWideUser(string userId, string role) => + new(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role), + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll), + }, + "test")); + + private static async Task SeedAsync(ApplicationDbContext context) + { + context.Accounts.Add(new Accounts { Id = 1, Name = "Acme Corp", IsDeleted = false }); + context.Users.Add(new ApplicationUser { Id = "admin-1", UserName = "admin-1", FirstName = "Ada", LastName = "Admin" }); + context.Users.Add(new ApplicationUser { Id = "dispatcher-1", UserName = "dispatcher-1", FirstName = "Dee", LastName = "Dispatcher" }); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = DispatchId, + VendorId = 1, + WorkOrderId = WorkOrderId, + NTEAmount = 2200m, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + context.workOrders.Add(new WorkOrder + { + Id = WorkOrderId, + InternalWONumber = "10000000001", + PrimaryDispatchId = DispatchId, + AccountId = 1, + WorkOrderType = WorkOrderType.PM, + LifecycleStatus = LifecycleStatus.Scheduled, + }); + // The admin filed this one themselves and it auto-approved within the allowance. + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = AutoApprovedId, + DispatchId = DispatchId, + CurrentNTE = 1000m, + RequestedNTE = 400m, + Status = UpliftStatus.NoApprovalRequired, + RequiredTier = 0, + NotificationStatus = "Sent", + createdby = "admin-1", + CreatedDate = DateTime.UtcNow.AddHours(-2), + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = AdminApprovedId, + DispatchId = DispatchId, + CurrentNTE = 1400m, + RequestedNTE = 800m, + Status = UpliftStatus.Approved, + RequiredTier = 1, + NotificationStatus = "Sent", + createdby = "dispatcher-1", + CreatedDate = DateTime.UtcNow.AddHours(-1), + DecidedAt = DateTime.UtcNow.AddMinutes(-30), + DecidedByUserId = "admin-1", + }); + await context.SaveChangesAsync(); + } + + private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) => + new( + new UpliftDataService(context), + new DispatchDataService(context), + new WorkOrderDetailDataService(context), + new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context)), + new UserDataService(context), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), + new VendorDocumentDataService(context)); + + private static async Task RevokeAsync( + ApplicationDbContext context, + RevokeRoute route, + ClaimsPrincipal user, + int upliftId, + string? reason) + { + var workOrderFlow = NewWorkOrderUpliftService(context); + var httpContext = new DefaultHttpContext { User = user }; + + if (route == RevokeRoute.WorkOrder) + { + var controller = new WorkOrderDetailController( + Mock.Of(), + Mock.Of(), + workOrderFlow, + Mock.Of(), + Mock.Of>()) + { + ControllerContext = new ControllerContext { HttpContext = httpContext }, + }; + return await controller.RevokeUplift( + WorkOrderId, + upliftId, + new RevokeWorkOrderUpliftRequestDto { Reason = reason }, + CancellationToken.None); + } + + var upliftService = new UpliftService( + new UpliftDataService(context), + new DispatchDataService(context), + Mock.Of(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), + workOrderFlow); + var approvals = new UpliftController(upliftService, Mock.Of>()) + { + ControllerContext = new ControllerContext { HttpContext = httpContext }, + }; + return await approvals.Revoke( + upliftId, + new UpliftController.DecisionRequest { Note = reason }, + CancellationToken.None); + } + + private static async Task AssertUnchangedAsync(ApplicationDbContext context, int upliftId, string status) + { + var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId); + stored.Status.Should().Be(status); + stored.DecisionNote.Should().BeNull(); + (await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(2200m); + (await context.WorkOrderAuditLogs.AsNoTracking().AnyAsync(a => a.Action == "uplift_revoke")).Should().BeFalse(); + } + + [Fact] + public async Task WorkOrderRoute_AdminRevokingAutoApprovedUpliftTheyRequested_IsForbiddenAndChangesNothing() + { + await using var context = CreateContext(); + await SeedAsync(context); + + var result = await RevokeAsync( + context, + RevokeRoute.WorkOrder, + OrgWideUser("admin-1", "Admin"), + AutoApprovedId, + "Wrong quote attached"); + + var refused = result.Should().BeOfType().Subject; + refused.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + refused.Value.Should().BeOfType().Which.Message + .Should().StartWith("You are not authorized to perform this action"); + await AssertUnchangedAsync(context, AutoApprovedId, UpliftStatus.NoApprovalRequired); + } + + [Fact] + public async Task ApprovalsRoute_AdminRevokingAutoApprovedUplift_IsRefusedAndChangesNothing() + { + await using var context = CreateContext(); + await SeedAsync(context); + + var result = await RevokeAsync( + context, + RevokeRoute.UpliftApprovals, + OrgWideUser("admin-1", "Admin"), + AutoApprovedId, + "Wrong quote attached"); + + var refused = result.Should().BeOfType().Subject; + refused.Value.Should().BeOfType().Which.Message + .Should().StartWith("This uplift request cannot be revoked"); + await AssertUnchangedAsync(context, AutoApprovedId, UpliftStatus.NoApprovalRequired); + } + + [Theory] + [InlineData(RevokeRoute.UpliftApprovals)] + [InlineData(RevokeRoute.WorkOrder)] + public async Task AdminRevokingAdminApprovedUpliftWithReason_Succeeds(RevokeRoute route) + { + await using var context = CreateContext(); + await SeedAsync(context); + + var result = await RevokeAsync( + context, + route, + OrgWideUser("admin-1", "Admin"), + AdminApprovedId, + " Approved against the wrong quote "); + + result.Should().BeOfType(); + var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == AdminApprovedId); + stored.Status.Should().Be(UpliftStatus.Revoked); + stored.DecisionNote.Should().Be("Approved against the wrong quote"); + (await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(1400m); + var audit = await context.WorkOrderAuditLogs.AsNoTracking().SingleAsync(a => a.Action == "uplift_revoke"); + audit.OldValue.Should().Be(UpliftStatus.Approved); + audit.NewValue.Should().Be(UpliftStatus.Revoked); + } + + [Theory] + [InlineData(RevokeRoute.UpliftApprovals)] + [InlineData(RevokeRoute.WorkOrder)] + public async Task AdminRevokingAdminApprovedUpliftWithoutReason_IsRefused(RevokeRoute route) + { + await using var context = CreateContext(); + await SeedAsync(context); + + var result = await RevokeAsync(context, route, OrgWideUser("admin-1", "Admin"), AdminApprovedId, " "); + + result.Should().BeOfType(); + await AssertUnchangedAsync(context, AdminApprovedId, UpliftStatus.Approved); + } + + [Fact] + public async Task WorkOrderRoute_DispatcherRevokingOwnAutoApprovedUpliftWithoutReason_Succeeds() + { + await using var context = CreateContext(); + await SeedAsync(context); + var own = await context.DispatchUpliftRequests.SingleAsync(u => u.Id == AutoApprovedId); + own.createdby = "dispatcher-1"; + await context.SaveChangesAsync(); + + var result = await RevokeAsync( + context, + RevokeRoute.WorkOrder, + OrgWideUser("dispatcher-1", "Dispatcher"), + AutoApprovedId, + null); + + result.Should().BeOfType(); + var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == AutoApprovedId); + stored.Status.Should().Be(UpliftStatus.Revoked); + stored.DecisionNote.Should().BeNull(); + (await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(1000m); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs b/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs index 9fa989c..a420208 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using Xunit; @@ -13,6 +14,58 @@ namespace Api.SeaHavenIndustries.Tests; public sealed class UpliftServiceRefusedTests { + [Fact] + public async Task RevokeAsync_DelegatesApprovedAdminRevocationToWorkOrderFlow() + { + var request = new DispatchUpliftRequest + { + Id = 7, + DispatchId = 42, + Status = "Approved", + RequiredTier = 1, + RequestedNTE = 900m + }; + var upliftData = new Mock(); + upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny())) + .ReturnsAsync(request); + upliftData.Setup(data => data.GetWorkOrderIdForUpliftAsync(7, It.IsAny())) + .ReturnsAsync(77); + var workOrderFlow = new Mock(); + workOrderFlow.Setup(flow => flow.RevokeAsync( + 77, + 7, + It.Is(r => r.Reason == "Policy change"), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new WorkOrderUpliftDto { Id = 7, Status = "revoked" }); + + var service = new UpliftService( + upliftData.Object, + Mock.Of(), + Mock.Of(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), + workOrderFlow.Object); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, "admin-1"), + new Claim(ClaimTypes.Role, "Admin") + }, + "test")); + + var result = await service.RevokeAsync(user, 7, " Policy change ", CancellationToken.None); + + result.Id.Should().Be(7); + result.Status.Should().Be("Revoked"); + workOrderFlow.Verify(flow => flow.RevokeAsync( + 77, + 7, + It.Is(r => r.Reason == "Policy change"), + It.IsAny(), + It.IsAny()), Times.Once); + } + [Fact] public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest() { diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs index 9b3f220..8c72a7e 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs @@ -139,7 +139,8 @@ public sealed class UpliftWorkflowTests private static VendorPortalService NewPortalService( ApplicationDbContext context, IEmailSender emailSender, - IVendorDocumentStoragePort? storage = null) + IVendorDocumentStoragePort? storage = null, + IUpliftDataService? upliftData = null) { var vendorData = new VendorDataService(context); var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions())); @@ -150,7 +151,7 @@ public sealed class UpliftWorkflowTests return new VendorPortalService( tokenService, new DispatchDataService(context), - new UpliftDataService(context), + upliftData ?? new UpliftDataService(context), commentData.Object, Mock.Of(), emailSender, @@ -214,6 +215,41 @@ public sealed class UpliftWorkflowTests result.RequiredTier.Should().Be(2); } + [Fact] + public async Task RequestUplift_ConcurrentActiveRequest_MapsPersistenceConflict() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + await context.SaveChangesAsync(); + + var upliftData = new Mock(); + upliftData.Setup(x => x.HasActiveAsync(dispatch.Id, It.IsAny())) + .ReturnsAsync(false); + upliftData.Setup(x => x.StageAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + upliftData.Setup(x => x.SaveChangesAsync(It.IsAny())) + .ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException()); + upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns((int _, Func> work, CancellationToken ct) => work(ct)); + + var service = NewPortalService(context, new FakeEmailSender(deliver: true), upliftData: upliftData.Object); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var act = () => service.RequestUpliftAsync( + session!, + dispatch.Id, + 1500m, + "reason", + null, + 1, + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + // --- Evidence scan + cross-vendor/dispatch scoping --- [Fact] @@ -388,6 +424,44 @@ public sealed class UpliftWorkflowTests context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_revised"); } + [Fact] + public async Task Revise_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 600m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + // Raised from the work order by an internal user and sent back for changes: + // RequestedNTE holds the 90 increase, not a total. + var workOrderRequest = new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 600m, + RequestedNTE = 90m, + Status = UpliftStatus.ChangesRequested, + RequiredTier = 1, + VendorReason = "Extra parts", + RequestedByVendorName = "Alex Dispatcher", + NotificationStatus = "Sent", + createdby = "dispatcher-1", + CreatedDate = new DateTime(2026, 3, 1), + }; + context.DispatchUpliftRequests.Add(workOrderRequest); + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + + var act = () => service.ReviseUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, 900m, "vendor total", 1, CancellationToken.None); + + await act.Should().ThrowAsync(); + var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id); + after.Status.Should().Be(UpliftStatus.ChangesRequested); + after.RequestedNTE.Should().Be(90m); + after.CurrentNTE.Should().Be(600m); + after.VendorReason.Should().Be("Extra parts"); + after.createdby.Should().Be("dispatcher-1"); + context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_revised"); + } + [Fact] public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt() { @@ -427,6 +501,92 @@ public sealed class UpliftWorkflowTests await act.Should().ThrowAsync(); } + // Raised from the work order by an internal user: createdby is set and RequestedNTE + // holds the increase. Vendor sessions have no identity user, so they never set it. + private static DispatchUpliftRequest WorkOrderRequest(int dispatchId, string status) => new() + { + DispatchId = dispatchId, + CurrentNTE = 600m, + RequestedNTE = 90m, + Status = status, + RequiredTier = 1, + VendorReason = "Extra parts", + RequestedByVendorName = "Alex Dispatcher", + NotificationStatus = "Sent", + createdby = "dispatcher-1", + CreatedDate = new DateTime(2026, 3, 1), + }; + + [Theory] + [InlineData("Pending", "withdraw")] + [InlineData("ChangesRequested", "withdraw")] + [InlineData("Pending", "cancel")] + [InlineData("ChangesRequested", "cancel")] + public async Task Withdraw_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged(string status, string route) + { + using var context = NewContext(); + var (_, _, dispatch) = await SeedAsync(context, nte: 600m); + var workOrderRequest = WorkOrderRequest(dispatch.Id, status); + context.DispatchUpliftRequests.Add(workOrderRequest); + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + + Func act = route == "cancel" + ? () => service.CancelUpliftRequestAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None) + : () => service.WithdrawUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None); + + await act.Should().ThrowAsync(); + var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id); + after.Status.Should().Be(status); + after.DecidedAt.Should().BeNull(); + after.RequestedNTE.Should().Be(90m); + after.createdby.Should().Be("dispatcher-1"); + context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_withdraw" || a.Action == "uplift_cancel"); + } + + [Fact] + public async Task Cancel_VendorRaisedChangesRequested_StillWithdraws() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None); + context.DispatchUpliftRequests.Single().Status = UpliftStatus.ChangesRequested; + await context.SaveChangesAsync(); + + var result = await service.CancelUpliftRequestAsync(session!, dispatch.Id, created.Id, CancellationToken.None); + + result.Status.Should().Be(UpliftStatus.Withdrawn); + context.DispatchUpliftRequests.AsNoTracking().Single().Status.Should().Be(UpliftStatus.Withdrawn); + context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_cancel" && a.OldValue == UpliftStatus.ChangesRequested); + } + + [Fact] + public async Task DispatchDetail_ReportsRaisedByVendor_PerCreationPath() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var vendorRaised = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None); + await service.WithdrawUpliftAsync(session!, dispatch.Id, vendorRaised.Id, CancellationToken.None); + var workOrderRequest = WorkOrderRequest(dispatch.Id, UpliftStatus.Pending); + context.DispatchUpliftRequests.Add(workOrderRequest); + await context.SaveChangesAsync(); + + var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + detail!.UpliftRequests.Should().HaveCount(2); + detail.UpliftRequests.Single(u => u.Id == vendorRaised.Id).RaisedByVendor.Should().BeTrue(); + detail.UpliftRequests.Single(u => u.Id == workOrderRequest.Id).RaisedByVendor.Should().BeFalse(); + } + [Fact] public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue() { @@ -650,6 +810,8 @@ public sealed class UpliftWorkflowTests var upliftData = new Mock(); upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { orphanReq, validReq }); + upliftData.Setup(u => u.GetWorkOrderIdForUpliftAsync(202, It.IsAny())) + .ReturnsAsync(99); upliftData.Setup(u => u.SaveChangesAsync(It.IsAny())) .Returns(Task.CompletedTask); @@ -884,4 +1046,69 @@ public sealed class UpliftWorkflowTests result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound); } + + // --- A cancelled work order takes no new uplift request --- + + [Theory] + [InlineData(true, "Canceled")] + [InlineData(false, "Cancelled")] + public async Task RequestUplift_OnCancelledWorkOrder_IsRefusedAndCreatesNothing(bool lifecycleCanceled, string statusText) + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + workOrder.Status = statusText; + if (lifecycleCanceled) + workOrder.LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled; + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 3500m, "reason", null, 1, CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*cancelled work order*"); + context.DispatchUpliftRequests.Should().BeEmpty(); + } + + [Fact] + public async Task RequestUplift_WaitsForAWorkOrderCancelInFlightAndIsThenRefused() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + using var context = new ApplicationDbContext(options); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + + // A work order cancel holding the work order's lock, committing only after the + // vendor's request has started. + var cancelEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseCancel = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancelContext = new ApplicationDbContext(options); + var cancel = new UpliftDataService(cancelContext).ExecuteWorkOrderMutationAsync(workOrder.Id, async ct => + { + cancelEntered.SetResult(); + await releaseCancel.Task; + var tracked = await cancelContext.workOrders.SingleAsync(w => w.Id == workOrder.Id, ct); + tracked.LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled; + await cancelContext.SaveChangesAsync(ct); + return true; + }, CancellationToken.None); + await cancelEntered.Task; + + var request = service.RequestUpliftAsync(session!, dispatch.Id, 3500m, "reason", null, 1, CancellationToken.None); + var finishedFirst = await Task.WhenAny(request, Task.Delay(TimeSpan.FromSeconds(2))); + finishedFirst.Should().NotBeSameAs(request, "the request must wait for the cancel holding the work order lock"); + + releaseCancel.SetResult(); + await cancel; + + await FluentActions.Awaiting(() => request).Should().ThrowAsync() + .WithMessage("*cancelled work order*"); + using var verify = new ApplicationDbContext(options); + verify.DispatchUpliftRequests.Should().BeEmpty(); + } } diff --git a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs index d2364bb..3ef307d 100644 --- a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs @@ -26,7 +26,8 @@ public class UserServiceTests Mock userData, Mock email, out Mock> store, - Mock? accounts = null) + Mock? accounts = null, + Mock? sessions = null) { var (manager, s, _) = IdentityTestHelpers.CreateUserManager(); store = s; @@ -34,7 +35,8 @@ public class UserServiceTests manager, userData.Object, (accounts ?? new Mock()).Object, - email.Object); + email.Object, + (sessions ?? new Mock()).Object); } [Fact] @@ -90,6 +92,23 @@ public class UserServiceTests userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny()), Times.Once); } + [Fact] + public async Task DeleteUser_AccountOwner_ReturnsForbiddenWithoutCascade() + { + var user = IdentityTestHelpers.User("owner"); + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("owner", It.IsAny())).ReturnsAsync(user); + userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny())).ReturnsAsync(true); + + var service = NewService(userData, new Mock(), out _); + + var outcome = await service.DeleteUserAsync("owner", Principal("Admin"), CancellationToken.None); + + outcome.Success.Should().BeFalse(); + outcome.Error.Should().Be(UserMutationErrors.Forbidden); + userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task DeleteUser_NonAdmin_ReturnsForbiddenWithoutCascade() { @@ -138,6 +157,26 @@ public class UserServiceTests outcome.Error.Should().Be(UserMutationErrors.UserNotFound); } + [Fact] + public async Task EditUser_AccountOwner_ReturnsForbiddenWithoutMutation() + { + var user = IdentityTestHelpers.User("owner"); + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("owner", It.IsAny())).ReturnsAsync(user); + userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny())).ReturnsAsync(true); + + var service = NewService(userData, new Mock(), out _); + + var outcome = await service.EditUserAsync( + new EditUserRequestDTO { Id = "owner", Email = "owner@example.com", Name = "Owner", Role = "User" }, + Principal("Admin"), + CancellationToken.None); + + outcome.Success.Should().BeFalse(); + outcome.Error.Should().Be(UserMutationErrors.Forbidden); + userData.Verify(u => u.UpdateUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task GetUserProfile_MapsCreatedDateToAddedDate() { @@ -392,4 +431,42 @@ public class UserServiceTests && password.Any(char.IsDigit) && password.Any(character => !char.IsLetterOrDigit(character)); } + + [Theory] + [InlineData(2, "alice@example.com", "Dispatcher", true)] + [InlineData(1, "alice@example.com", "Scheduler", true)] + [InlineData(1, "alice.new@example.com", "Dispatcher", true)] + [InlineData(1, "ALICE@example.com", "dispatcher", false)] + public async Task EditUser_EndsEarlierSessionsOnlyWhenTheTokenClaimsChange( + int accountId, string email, string role, bool endsSessions) + { + var existing = IdentityTestHelpers.User("u1", userName: "alice@example.com"); + existing.AccountId = 1; + var stampBefore = existing.SecurityStamp; + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(existing); + var accounts = new Mock(); + accounts.Setup(a => a.ExistsAsync(It.IsAny())).ReturnsAsync(true); + var sessions = new Mock(); + var service = NewService(userData, new Mock(), out var store, accounts, sessions); + store.Setup(s => s.GetRolesAsync(existing, It.IsAny())) + .ReturnsAsync(new List { "Dispatcher" }); + + var outcome = await service.EditUserAsync( + new EditUserRequestDTO { Id = "u1", Name = "Alice", Email = email, Role = role, AccountId = accountId }, + Principal("Admin"), + CancellationToken.None); + + outcome.Success.Should().BeTrue(); + if (endsSessions) + { + existing.SecurityStamp.Should().NotBe(stampBefore); + sessions.Verify(s => s.Forget("u1"), Times.Once); + } + else + { + existing.SecurityStamp.Should().Be(stampBefore); + sessions.Verify(s => s.Forget(It.IsAny()), Times.Never); + } + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs index b10a37d..e57a307 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs @@ -105,14 +105,14 @@ public class VendorCompanyRosterControllerTests var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); result.Should().BeOfType(); - service.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Create_Returns200() { var service = new Mock(); - service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ReturnsAsync(SampleRoster()); var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -125,7 +125,7 @@ public class VendorCompanyRosterControllerTests public async Task Create_ValidationException_Returns400() { var service = new Mock(); - service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("At least one company phone or email is required.")); var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None); @@ -137,7 +137,7 @@ public class VendorCompanyRosterControllerTests public async Task Create_DuplicateName_ReturnsStable409() { var service = new Mock(); - service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorRosterDuplicateNameException( "database detail", new InvalidOperationException("IX_VendorCompanies_NormalizedName"))); @@ -164,14 +164,14 @@ public class VendorCompanyRosterControllerTests var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); result.Should().BeOfType(); - service.Verify(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.Verify(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Reconcile_ValidationException_Returns400() { var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed.")); var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -183,7 +183,7 @@ public class VendorCompanyRosterControllerTests public async Task Reconcile_CompanyNotFound_Returns404() { var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new KeyNotFoundException("not found")); var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -199,7 +199,7 @@ public class VendorCompanyRosterControllerTests new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled } }; var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorRosterConflictException("blocked", blocked)); var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -212,7 +212,7 @@ public class VendorCompanyRosterControllerTests public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText() { var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ...")); var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -251,14 +251,14 @@ public class VendorCompanyRosterControllerTests var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None); result.Should().BeOfType(); - service.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Patch_AddsTechnicians_Returns200WithRoster() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) .ReturnsAsync(SampleRoster()); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); @@ -272,7 +272,7 @@ public class VendorCompanyRosterControllerTests public async Task Patch_ValidationException_Returns400() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians.")); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); @@ -285,7 +285,7 @@ public class VendorCompanyRosterControllerTests public async Task Patch_CompanyNotFound_Returns404() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new KeyNotFoundException("not found")); var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None); @@ -297,7 +297,7 @@ public class VendorCompanyRosterControllerTests public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ...")); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); @@ -306,4 +306,29 @@ public class VendorCompanyRosterControllerTests conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); conflict.Value!.ToString()!.Should().NotContain("internal provider detail"); } + + [Fact] + public async Task AreaAssignmentForbidden_Returns403OnEveryMutation() + { + var service = new Mock(); + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); + service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); + service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); + var controller = NewController(service); + + var results = new[] + { + await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }, CancellationToken.None), + await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", AreaId = 1 }, CancellationToken.None), + await controller.AddTechnicians(7, new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }, CancellationToken.None) + }; + + foreach (var result in results) + result.Should().BeOfType().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + + service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny(), "42", controller.User, It.IsAny()), Times.Once); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs index 6114c31..cd7627c 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs @@ -835,4 +835,43 @@ public class VendorCompanyRosterDataServiceTests await act.Should().ThrowAsync(); } + + [Fact] + public async Task SaveRoster_AppliesAreaOnlyWhenProvidedAndReadsAreaName() + { + var dbName = Guid.NewGuid().ToString(); + using var context = NewContext(dbName); + context.Areas.AddRange( + new Area { Id = 1, Name = "East", NormalizedName = "east" }, + new Area { Id = 2, Name = "Central", NormalizedName = "central" }); + await context.SaveChangesAsync(); + var (companyId, _) = await SeedCompanyWithTechnicianAsync(context, "Area Co", "Tech"); + var service = new VendorCompanyRosterDataService(context); + + VendorCompanyRosterWriteModel Snapshot(bool provided, int? areaId) => new() + { + CompanyId = companyId, + Name = "Area Co", + Email = "area@example.com", + AreaIdProvided = provided, + AreaId = areaId, + Technicians = context.Vendors.Where(v => v.CompanyId == companyId) + .Select(v => new RosterTechnicianWriteModel { Id = v.Id, ContactName = v.ContactName }) + .ToList() + }; + + var assigned = await service.SaveRosterAsync(Snapshot(true, 2), CancellationToken.None); + assigned.AreaId.Should().Be(2); + assigned.AreaName.Should().Be("Central"); + + var untouched = await service.SaveRosterAsync(Snapshot(false, null), CancellationToken.None); + untouched.AreaId.Should().Be(2); + + var cleared = await service.SaveRosterAsync(Snapshot(true, null), CancellationToken.None); + cleared.AreaId.Should().BeNull(); + cleared.AreaName.Should().BeNull(); + + (await service.IsActiveAreaAsync(1, CancellationToken.None)).Should().BeTrue(); + (await service.IsActiveAreaAsync(42, CancellationToken.None)).Should().BeFalse(); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs index 5991fd5..72b29db 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using FluentValidation; using Microsoft.Extensions.DependencyInjection; using Moq; +using System.Security.Claims; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; @@ -13,6 +14,13 @@ namespace Api.SeaHavenIndustries.Tests; public class VendorCompanyRosterServiceTests { + private static readonly ClaimsPrincipal Dispatcher = Principal("User"); + private static readonly ClaimsPrincipal Admin = Principal("Admin"); + + private static ClaimsPrincipal Principal(string role) => new(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.Role, role) }, + "Test")); + private static VendorCompanyRosterService NewService(Mock data) => new(data.Object); private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new() @@ -48,7 +56,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme" }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -60,7 +68,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone)); @@ -78,7 +86,7 @@ public class VendorCompanyRosterServiceTests Technicians = new List { new() { Id = 7, ContactName = "Riley" } } }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -98,7 +106,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Technicians = new List() - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.Technicians.Should().BeEmpty(); @@ -120,7 +128,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Notes = notes - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Notes.Should().Be(notes); } @@ -136,7 +144,7 @@ public class VendorCompanyRosterServiceTests Notes = new string('n', 501) }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => @@ -163,7 +171,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -188,7 +196,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -205,7 +213,7 @@ public class VendorCompanyRosterServiceTests GoogleMapsUrl = "http://maps.google.com/acme" }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl)); @@ -228,7 +236,7 @@ public class VendorCompanyRosterServiceTests { new() { ContactName = "Riley", Phone = "+1 312 555 0100" } } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100"); } @@ -247,7 +255,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); @@ -270,7 +278,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme Co", Email = "acme@example.com", Technicians = new List { new() { Id = 1, ContactName = "T" } } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.CompanyId.Should().Be(7); @@ -294,7 +302,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Notes = notes - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Notes.Should().Be(notes); } @@ -311,7 +319,7 @@ public class VendorCompanyRosterServiceTests Notes = new string('n', 501) }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => @@ -336,7 +344,7 @@ public class VendorCompanyRosterServiceTests Email = "acme@example.com" }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); @@ -354,7 +362,7 @@ public class VendorCompanyRosterServiceTests Email = "acme@example.com" }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); @@ -376,7 +384,7 @@ public class VendorCompanyRosterServiceTests Technicians = null! }; - await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + await NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Should().BeEmpty(); } @@ -397,7 +405,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -420,7 +428,7 @@ public class VendorCompanyRosterServiceTests { new() { ContactName = "", Phone = "", PreferredContact = "Phone" } } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Should().ContainSingle(); captured.Technicians.Single().ContactName.Should().BeEmpty(); @@ -442,7 +450,7 @@ public class VendorCompanyRosterServiceTests Technicians = new List { new() { Id = 1, ContactName = "T" } } }; - var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); } @@ -462,7 +470,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { Id = 1, ContactName = "T" } } - }, "42", cts.Token); + }, "42", Dispatcher, cts.Token); data.Verify(x => x.SaveRosterAsync(It.IsAny(), cts.Token), Times.Once); } @@ -477,7 +485,7 @@ public class VendorCompanyRosterServiceTests public async Task AddTechnicians_WithoutAuthenticatedUser_Throws() { var data = new Mock(); - var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -488,7 +496,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" }); - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians)); @@ -501,7 +509,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -517,7 +525,7 @@ public class VendorCompanyRosterServiceTests AddTechnicians = new List { new() { ContactName = "T" } } }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion)); @@ -530,7 +538,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" }); - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); @@ -560,7 +568,7 @@ public class VendorCompanyRosterServiceTests } }; - await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.CompanyId.Should().Be(7); @@ -590,7 +598,7 @@ public class VendorCompanyRosterServiceTests { RowVersion = "AAAAAAAAD8I=", CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.CompanyFields!.Notes.Should().Be(notes); } @@ -605,7 +613,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) } }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => @@ -632,7 +640,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" } }; - await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); // SH-250: every field is blank, so there is no company change to apply. Forwarding a // non-null write model made the data layer bump RowVersion and rewrite every @@ -652,7 +660,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" } }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify( @@ -676,7 +684,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" } }; - await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); captured!.CompanyFields.Should().NotBeNull(); captured.CompanyFields!.City.Should().Be("Norfolk"); @@ -690,8 +698,182 @@ public class VendorCompanyRosterServiceTests .ReturnsAsync(SampleReadModel(7, 1)); using var cts = new CancellationTokenSource(); - await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token); + await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", Dispatcher, cts.Token); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), cts.Token), Times.Once); } + + private static VendorCompanyRosterReadModel ReadModelWithArea(int? areaId) + { + var model = SampleReadModel(7, 1); + model.AreaId = areaId; + return model; + } + + [Fact] + public async Task Create_NonAdminAssigningArea_IsForbiddenAndNotPersisted() + { + var data = new Mock(); + var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation() + { + var data = new Mock(); + data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny())).ReturnsAsync(false); + var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 99 }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", Admin, CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId)); + data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_AdminAssigningActiveArea_PersistsAreaId() + { + using var cts = new CancellationTokenSource(); + var data = new Mock(); + data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(ReadModelWithArea(2)); + + var result = await NewService(data).CreateRosterAsync( + new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 2 }, + "42", + Admin, + cts.Token); + + captured!.AreaId.Should().Be(2); + result.AreaId.Should().Be(2); + data.Verify(x => x.IsActiveAreaAsync(2, cts.Token), Times.Once); + } + + [Fact] + public async Task Reconcile_NonAdminChangingOrClearingArea_IsForbiddenAndNotPersisted() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(1)); + + foreach (int? requested in new int?[] { 3, null }) + { + var dto = new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme", + Email = "acme@example.com", + AreaId = requested + }; + + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(1)); + var captured = new List(); + data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured.Add(model)) + .ReturnsAsync(ReadModelWithArea(1)); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme Renamed", + Email = "acme@example.com", + AreaId = 1 + }, "42", Dispatcher, CancellationToken.None); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme Renamed", + Email = "acme@example.com" + }, "42", Dispatcher, CancellationToken.None); + + captured.Should().HaveCount(2); + captured[0].AreaIdProvided.Should().BeTrue(); + captured[0].AreaId.Should().Be(1); + captured[1].AreaIdProvided.Should().BeFalse(); + } + + [Fact] + public async Task Reconcile_AdminClearsAreaToUnassigned() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(1)); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(ReadModelWithArea(null)); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme", + Email = "acme@example.com", + AreaId = null + }, "42", Admin, CancellationToken.None); + + captured!.AreaIdProvided.Should().BeTrue(); + captured.AreaId.Should().BeNull(); + data.Verify(x => x.IsActiveAreaAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddTechnicians_NonAdminAreaChange_IsForbidden() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(null)); + var dto = new AddTechniciansVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + CompanyFields = new VendorRosterCompanyFieldsDTO { AreaId = 2 } + }; + + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull() + { + var omitted = System.Text.Json.JsonSerializer.Deserialize( + """{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""", + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + var cleared = System.Text.Json.JsonSerializer.Deserialize( + """{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""", + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + var assigned = System.Text.Json.JsonSerializer.Deserialize( + """{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":3}""", + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + + omitted.AreaIdProvided.Should().BeFalse(); + cleared.AreaIdProvided.Should().BeTrue(); + cleared.AreaId.Should().BeNull(); + assigned.AreaIdProvided.Should().BeTrue(); + assigned.AreaId.Should().Be(3); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs index e0df2a9..e0d34e0 100644 --- a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -39,7 +39,7 @@ public class VendorControllerTests var service = new Mock(); using var cancellation = new CancellationTokenSource(); var token = cancellation.Token; - service.Setup(x => x.GetVendorDirectoryPagedAsync( + service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync( 1, 10, "clayton", @@ -53,7 +53,7 @@ public class VendorControllerTests await NewController(service).GetVendorList(search: "clayton", cancellationToken: token); - service.Verify(x => x.GetVendorDirectoryPagedAsync( + service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync( 1, 10, "clayton", @@ -63,6 +63,178 @@ public class VendorControllerTests It.IsAny?>(), It.IsAny?>(), token), Times.Once); + service.Verify(x => x.GetVendorCompanyDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetVendorList_ReturnsIndividualTechnicianRowsWithoutGroupedTechnicians() + { + var service = new Mock(); + service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(new PagedResult + { + Items = new List + { + new() { Id = 7, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech A" }, + new() { Id = 9, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech B" } + }, + TotalCount = 2, + Page = 1, + PageSize = 10 + }); + + var result = await NewController(service).GetVendorList(); + + var ok = result.Should().BeOfType().Subject; + var paged = ok.Value.Should().BeOfType().Subject; + var rows = ((System.Collections.IEnumerable)paged.Data!).Cast().ToList(); + + // Legacy contract: same-company technicians stay as distinct top-level rows + // carrying their own vendor ids, and the response shape has no grouped + // Technicians property. + rows.Should().HaveCount(2); + rows.Select(r => r.GetType().GetProperty("Id")!.GetValue(r)).Should().BeEquivalentTo(new[] { 7, 9 }); + rows.Should().OnlyContain(r => r.GetType().GetProperty("Technicians") == null); + } + + [Fact] + public async Task GetVendorList_PreservesLegacyPageSizeAboveGroupedDirectoryBound() + { + var service = new Mock(); + service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync( + 1, + 500, + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(new PagedResult { TotalCount = 420, Page = 1, PageSize = 500 }); + + var result = await NewController(service).GetVendorList(page: 1, pageSize: 500); + + var ok = result.Should().BeOfType().Subject; + var paged = ok.Value.Should().BeOfType().Subject; + paged.PageSize.Should().Be(500); + paged.TotalPages.Should().Be(1); + service.VerifyAll(); + } + + [Fact] + public async Task GetVendorDirectoryList_DelegatesToCompanyDirectoryServiceWithNestedTechnicians() + { + var service = new Mock(); + using var cancellation = new CancellationTokenSource(); + var token = cancellation.Token; + service.Setup(x => x.GetVendorCompanyDirectoryPagedAsync( + 1, + 10, + "clayton", + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token)) + .ReturnsAsync(new PagedResult + { + Items = new List + { + new() + { + Id = 9, + CompanyId = 3, + CompanyName = "Gateway Plumbing", + Email = "company@gatewayplumbing.example", + TotalJobs = 6, + Technicians = new List + { + new() { Id = 7, ContactName = "Tech A", Email = "tech.a@example.com" }, + new() { Id = 9, ContactName = "Tech B", Email = "tech.b@example.com" } + } + } + }, + TotalCount = 1, + Page = 1, + PageSize = 10 + }); + + var result = await NewController(service).GetVendorDirectoryList(search: "clayton", cancellationToken: token); + + var ok = result.Should().BeOfType().Subject; + var paged = ok.Value.Should().BeOfType().Subject; + var company = ((System.Collections.IEnumerable)paged.Data!).Cast().Single(); + + // SH-281: company row with nested technicians; top-level Email stays the + // company email while technician emails stay on the nested rows. + company.GetType().GetProperty("Email")!.GetValue(company).Should().Be("company@gatewayplumbing.example"); + var technicians = (System.Collections.IEnumerable)company.GetType().GetProperty("Technicians")!.GetValue(company)!; + technicians.Cast().Select(t => t.GetType().GetProperty("Id")!.GetValue(t)) + .Should().BeEquivalentTo(new[] { 7, 9 }); + technicians.Cast().Select(t => t.GetType().GetProperty("Email")!.GetValue(t)) + .Should().BeEquivalentTo(new[] { "tech.a@example.com", "tech.b@example.com" }); + + service.Verify(x => x.GetVendorCompanyDirectoryPagedAsync( + 1, + 10, + "clayton", + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token), Times.Once); + service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny()), Times.Never); + } + + [Fact] + public void VendorDirectoryEndpoints_AdvertiseDistinctCompatibilityAndCompanyRoutes() + { + var legacyRoutes = typeof(VendorController) + .GetMethod(nameof(VendorController.GetVendorList))! + .GetCustomAttributes(typeof(HttpGetAttribute), inherit: false) + .Cast() + .Select(attribute => attribute.Template); + var companyRoutes = typeof(VendorController) + .GetMethod(nameof(VendorController.GetVendorDirectoryList))! + .GetCustomAttributes(typeof(HttpGetAttribute), inherit: false) + .Cast() + .Select(attribute => attribute.Template); + + legacyRoutes.Should().BeEquivalentTo("GetVendorList"); + companyRoutes.Should().BeEquivalentTo("GetVendorDirectoryList"); } [Fact] diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 3473085..4fc5ee1 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Models; +using System.Text.RegularExpressions; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -327,6 +328,160 @@ public class VendorDataServiceTests result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems(); } + [Fact] + public async Task GetCompanyDirectoryPagedAsync_GroupsCompaniesAndKeepsLegacyRowsDistinct() + { + await using var context = NewContext(); + var linkedCompany = new VendorCompany + { + Name = "Linked Co", + NormalizedName = "linked co", + Email = "dispatch@linked.example", + CompanyPhone = "555-0100", + Address = "100 Company Way", + City = "Chicago", + State = "IL" + }; + var companyOnly = new VendorCompany { Name = "Company Only", NormalizedName = "company only" }; + context.VendorCompanies.AddRange(linkedCompany, companyOnly); + context.Vendors.AddRange( + new Vendor + { + Company = linkedCompany, + CompanyName = "Stale Linked Name", + ContactName = "Older Tech", + Email = "older@linked.example", + Phone = "555-0101", + TradeSpecialties = "Plumbing", + IsActive = true, + IsDeleted = false, + Dispatches = new List { new() { WorkOrderId = 101 } } + }, + new Vendor + { + Company = linkedCompany, + CompanyName = "Another Stale Name", + ContactName = "Newer Tech", + Email = "newer@linked.example", + Phone = "555-0102", + TradeSpecialties = "HVAC, plumbing", + IsActive = true, + IsDeleted = false, + Dispatches = new List { new() { WorkOrderId = 102 } } + }, + new Vendor { CompanyName = " Legacy Co ", ContactName = "Legacy One", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "legacy co", ContactName = "Legacy Two", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = " ", ContactName = "Unnamed One", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = null, ContactName = "Unnamed Two", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var (items, totalCount) = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true); + + totalCount.Should().Be(5); + var linked = items.Single(item => item.CompanyId == linkedCompany.Id); + linked.CompanyName.Should().Be("Linked Co"); + linked.Email.Should().Be("dispatch@linked.example"); + linked.ContactName.Should().Be("Newer Tech"); + linked.Technicians.Should().HaveCount(2); + linked.TotalJobs.Should().Be(2); + linked.TradeSpecialties.Should().Be("HVAC, plumbing"); + + var canonicalSearch = await service.GetCompanyDirectoryPagedAsync( + 1, 50, search: "Linked Co", isActive: true); + canonicalSearch.Items.Should().ContainSingle(item => item.CompanyId == linkedCompany.Id); + + var canonicalCompanyFilter = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, companies: new[] { "Linked Co" }); + canonicalCompanyFilter.Items.Should().ContainSingle(item => item.CompanyId == linkedCompany.Id); + + var legacy = items.Single(item => + item.Technicians.Any(tech => tech.ContactName == "Legacy One")); + legacy.Technicians.Select(tech => tech.ContactName) + .Should().BeEquivalentTo("Legacy One", "Legacy Two"); + items.Count(item => item.Technicians.Count == 1 && + item.Technicians.Single().ContactName?.StartsWith("Unnamed") == true).Should().Be(2); + items.Single(item => item.CompanyId == companyOnly.Id).Technicians.Should().BeEmpty(); + } + + [Fact] + public async Task GetCompanyDirectoryPagedAsync_FilterSelectsGroupAndSearchPromotesMatchingTechnician() + { + await using var context = NewContext(); + var company = new VendorCompany { Name = "Search Co", NormalizedName = "search co" }; + context.VendorCompanies.Add(company); + var older = new Vendor + { + Company = company, + CompanyName = company.Name, + ContactName = "Alice Plumber", + TradeSpecialties = "Plumbing", + IsActive = true, + IsDeleted = false, + Dispatches = new List { new() { WorkOrderId = 201 } } + }; + var newer = new Vendor + { + Company = company, + CompanyName = company.Name, + ContactName = "Zed HVAC", + TradeSpecialties = "HVAC", + IsActive = true, + IsDeleted = false, + Dispatches = new List { new() { WorkOrderId = 202 } } + }; + var inactive = new Vendor + { + Company = company, + CompanyName = company.Name, + ContactName = "Inactive Tech", + TradeSpecialties = "Roofing", + IsActive = false, + IsDeleted = false, + Dispatches = new List { new() { WorkOrderId = 203 } } + }; + context.Vendors.AddRange(older, newer, inactive); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var search = await service.GetCompanyDirectoryPagedAsync(1, 10, search: "Alice", isActive: true); + var trade = await service.GetCompanyDirectoryPagedAsync( + 1, 10, isActive: true, trades: new[] { "Plumbing" }); + + search.TotalCount.Should().Be(1); + search.Items.Single().Id.Should().Be(older.Id); + search.Items.Single().ContactName.Should().Be("Alice Plumber"); + search.Items.Single().Technicians.Should().HaveCount(2); + search.Items.Single().TotalJobs.Should().Be(2); + + trade.TotalCount.Should().Be(1); + trade.Items.Single().Id.Should().Be(newer.Id); + trade.Items.Single().Technicians.Should().HaveCount(2); + trade.Items.Single().Technicians.Should().NotContain(tech => tech.Id == inactive.Id); + } + + [Fact] + public void BuildCompanyDirectoryKeysQuery_TranslatesGroupingAndFacetFiltersToSqlServer() + { + using var context = NewSqlServerContext(); + var service = new VendorDataService(context); + + var sql = service.BuildCompanyDirectoryKeysQuery( + search: "alice", + trades: new[] { "Plumbing" }, + locations: new[] { "Chicago, IL" }, + jobBuckets: new[] { "under-50" }, + areaFilter: new VendorAreaFilter { AreaIds = new[] { 1, 2 }, IncludeUnassigned = true }).ToQueryString(); + + sql.Should().Contain("GROUP BY"); + sql.Should().Contain("LEFT JOIN"); + sql.Should().Contain("TradeSpecialties"); + sql.Should().Contain("TotalJobs"); + sql.Should().Contain("AreaId"); + Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase) + .Should().BeFalse("company totals must sum pre-aggregated scalar job counts"); + } + [Fact] public async Task GetDirectoryPagedAsync_IncludesCompanyOnlyRowsAndPreservesTechnicianRows() { @@ -891,4 +1046,69 @@ public class VendorDataServiceTests sql.Should().Contain("SELECT"); sql.Should().Contain("VendorCompanies"); } + + [Fact] + public async Task GetCompanyDirectoryPagedAsync_AreaFilterOrsWithinFacetAndUnassignedIncludesLegacyRows() + { + await using var context = NewContext(); + var east = new Area { Id = 1, Name = "East", NormalizedName = "east" }; + var west = new Area { Id = 3, Name = "West", NormalizedName = "west" }; + context.Areas.AddRange(east, west); + var eastCo = new VendorCompany { Name = "East Co", NormalizedName = "east co", Area = east }; + var westCo = new VendorCompany { Name = "West Co", NormalizedName = "west co", Area = west }; + var unassignedCo = new VendorCompany { Name = "Unassigned Co", NormalizedName = "unassigned co" }; + var eastOnly = new VendorCompany { Name = "East Only", NormalizedName = "east only", Area = east }; + context.VendorCompanies.AddRange(eastCo, westCo, unassignedCo, eastOnly); + context.Vendors.AddRange( + new Vendor { Company = eastCo, CompanyName = "East Co", ContactName = "E", City = "Boston", State = "MA", IsActive = true, IsDeleted = false }, + new Vendor { Company = westCo, CompanyName = "West Co", ContactName = "W", IsActive = true, IsDeleted = false }, + new Vendor { Company = unassignedCo, CompanyName = "Unassigned Co", ContactName = "U", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "Legacy Co", ContactName = "L", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var eastResult = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } }); + eastResult.Items.Select(item => item.CompanyName) + .Should().BeEquivalentTo("East Co", "East Only"); + eastResult.TotalCount.Should().Be(2); + var eastRow = eastResult.Items.Single(item => item.CompanyName == "East Co"); + eastRow.AreaId.Should().Be(east.Id); + eastRow.AreaName.Should().Be("East"); + eastResult.Items.Single(item => item.CompanyName == "East Only").AreaName.Should().Be("East"); + + var eastOrUnassigned = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, + areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id }, IncludeUnassigned = true }); + eastOrUnassigned.Items.Select(item => item.CompanyName) + .Should().BeEquivalentTo("East Co", "East Only", "Unassigned Co", "Legacy Co"); + eastOrUnassigned.Items.Single(item => item.CompanyName == "Legacy Co").AreaId.Should().BeNull(); + + var eastAndBoston = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, locations: new[] { "Boston, MA" }, + areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } }); + eastAndBoston.Items.Should().ContainSingle(item => item.CompanyName == "East Co"); + + var matchesNobody = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, areaFilter: new VendorAreaFilter()); + matchesNobody.TotalCount.Should().Be(0); + matchesNobody.Items.Should().BeEmpty(); + + var unfiltered = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true); + unfiltered.TotalCount.Should().Be(5); + } + + [Fact] + public async Task GetActiveAreasAsync_ExcludesArchivedAreas() + { + await using var context = NewContext(); + context.Areas.AddRange( + new Area { Id = 1, Name = "East", NormalizedName = "east" }, + new Area { Id = 4, Name = "California", NormalizedName = "california", IsActive = false }); + await context.SaveChangesAsync(); + + var areas = await new VendorDataService(context).GetActiveAreasAsync(CancellationToken.None); + + areas.Select(area => area.Name).Should().Equal("East"); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs index dd9e40e..bc097b5 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; using Xunit; @@ -309,6 +310,34 @@ public class VendorPortalControllerTests result.Should().BeOfType(); } + [Fact] + public async Task RequestUplift_ConcurrentActiveRequest_ReturnsConflict() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.RequestUpliftAsync( + Session, + 10, + 1500m, + "Need more parts", + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftConflictException()); + var controller = NewController(service); + + var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody + { + RequestedNTE = 1500m, + Reason = "Need more parts", + }); + + var conflict = result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); + conflict.Value.Should().NotBeNull(); + } + [Fact] public async Task RequestUplift_Success_ReturnsResult() { diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 0d8f6e3..e454b5f 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -54,6 +54,11 @@ public sealed class VendorPortalDocumentTests : IDisposable var upliftData = new Mock(); upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); + upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns((int _, Func> work, CancellationToken ct) => work(ct)); var commentData = new Mock(); commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); @@ -363,6 +368,278 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().HaveCount(1); } + [Fact] + public void UploadCompletionDocument_AdvertisesContractRequestLimit() + { + var attribute = Assert.Single( + typeof(VendorPortalController) + .GetMethod(nameof(VendorPortalController.UploadCompletionDocument))! + .CustomAttributes, + candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); + var bytes = Assert.Single(attribute.ConstructorArguments); + + bytes.Value.Should().Be(110_000_000L); + } + + private static byte[] MediaBytes(int size, params byte[] header) + { + var bytes = new byte[size]; + header.AsSpan().CopyTo(bytes); + return bytes; + } + + private static byte[] FtypVideoBytes(int size) => MediaBytes( + size, 0x00, 0x00, 0x00, 0x20, (byte)'f', (byte)'t', (byte)'y', (byte)'p', + (byte)'i', (byte)'s', (byte)'o', (byte)'m'); + + [Fact] + public async Task UploadCompletionDocument_AcceptsSixtyMegabyteVideo() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(60_000_000), "site-clip.mp4", "video/mp4")); + + result.Should().BeOfType(); + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.ContentType.Should().Be("video/mp4"); + document.SizeBytes.Should().Be(60_000_000L); + } + + [Fact] + public async Task UploadCompletionDocument_AcceptsMovWithEmptyContentType() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "")); + + result.Should().BeOfType(); + (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("video/quicktime"); + } + + [Fact] + public async Task UploadCompletionDocument_AcceptsIosTranscodedJpegLabelledHeic() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(MediaBytes(4_096, 0xFF, 0xD8, 0xFF, 0xE0), "IMG_2044.jpg", "image/heic")); + + result.Should().BeOfType(); + (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg"); + } + + [Theory] + // Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must + // follow the validated type, not the name, or the document/photo caps are bypassed. + [InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D })] + [InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })] + public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap( + string fileName, + string contentType, + int size, + byte[] header) + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(MediaBytes(size, header), fileName, contentType)); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + /// ftyp + mdat + moov/mvhd (moov last, as phones write it). + private static byte[] PhoneVideoBytes(uint durationSeconds) + { + static byte[] Box(string type, byte[] body) + { + var box = new byte[8 + body.Length]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length); + System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4); + body.CopyTo(box, 8); + return box; + } + + var mvhd = new byte[20]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 600); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 600); + return Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0")) + .Concat(Box("mdat", new byte[4096])) + .Concat(Box("moov", Box("mvhd", mvhd))) + .ToArray(); + } + + [Theory] + [InlineData(95u, false)] + [InlineData(89u, true)] + public async Task UploadCompletionDocument_EnforcesNinetySecondVideoLimit(uint seconds, bool accepted) + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(PhoneVideoBytes(seconds), "IMG_0042.MOV", "video/quicktime")); + + if (accepted) + { + result.Should().BeOfType(); + } + else + { + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + } + + [Fact] + public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "clip.mp4" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = dispatch.WorkOrderId!.Value, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + }); + } + await context.SaveChangesAsync(); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "video/quicktime")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_NewVersionDoesNotCountTheVideoItReplaces() + { + using var context = NewContext(); + var (vendor, dispatch) = await SeedDispatch(context); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = dispatch.WorkOrderId!.Value, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + }); + } + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = dispatch.WorkOrderId!.Value, + ContentType = "video/mp4", + StoredFileName = "v1.mp4", + Version = 1, + Purpose = "Completion" + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(2_048), "site-clip-v2.mp4", "video/mp4")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().HaveCount(2); + } + + [Fact] + public async Task GetDispatchDetail_ReportsWorkOrderMediaCountsForThePortalPreCheck() + { + using var context = NewContext(); + var (vendor, dispatch) = await SeedDispatch(context); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "IMG_0003.jpg" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = dispatch.WorkOrderId!.Value, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + }); + } + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = dispatch.WorkOrderId!.Value, + ContentType = "video/mp4", + StoredFileName = "v1.mp4", + Version = 1, + Purpose = "Completion" + }); + await context.SaveChangesAsync(); + + var service = NewService(context); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + detail!.MediaCounts.Should().BeEquivalentTo(new SeaHaven.Services.DTOs.PortalMediaCountsDTO + { + MaxPhotos = 10, + MaxVideos = 3, + Photos = 1, + Videos = 3, + CompletionPhotos = 1, + CompletionVideos = 2, + }); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(100_000_001), "site-clip.mp4", "video/mp4")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsPhotoOverTenMegabytes() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(MediaBytes(10_000_001, 0xFF, 0xD8, 0xFF, 0xE0), "photo.jpg", "image/jpeg")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsUnsupportedVideoContainer() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile("not a video at all"u8.ToArray(), "clip.mp4", "video/mp4")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + [Fact] public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata() { diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 42c22bf..48351df 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -152,12 +152,12 @@ public class VendorServiceTests } [Fact] - public async Task GetVendorDirectoryPaged_ForwardsCancellationToken() + public async Task GetVendorCompanyDirectoryPaged_ForwardsCancellationTokenAndMapsNestedTechnicians() { var data = new Mock(); using var cancellation = new CancellationTokenSource(); var token = cancellation.Token; - data.Setup(x => x.GetDirectoryPagedAsync( + data.Setup(x => x.GetCompanyDirectoryPagedAsync( 1, 12, "clayton", @@ -166,17 +166,45 @@ public class VendorServiceTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny(), token)) - .ReturnsAsync((Array.Empty(), 0)); + .ReturnsAsync((new[] + { + new VendorCompanyGroup + { + Id = 7, + CompanyId = 3, + CompanyName = "Gateway Plumbing", + ContactName = "Primary Tech", + Email = "company@gatewayplumbing.example", + TotalJobs = 4, + Technicians = new[] + { + new VendorCompanyGroupTechnician + { + Id = 7, + ContactName = "Primary Tech", + Email = "primary@example.com", + TotalJobs = 4, + IsActive = true + } + } + } + }.AsEnumerable().ToList(), 1)); - await NewService(data).GetVendorDirectoryPagedAsync( + var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync( 1, 12, "clayton", true, cancellationToken: token); - data.Verify(x => x.GetDirectoryPagedAsync( + var group = result.Items.Single(); + group.Email.Should().Be("company@gatewayplumbing.example"); + group.Technicians.Should().ContainSingle() + .Which.Email.Should().Be("primary@example.com"); + + data.Verify(x => x.GetCompanyDirectoryPagedAsync( 1, 12, "clayton", @@ -185,6 +213,80 @@ public class VendorServiceTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny(), + token), Times.Once); + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_BoundsPageInputsBeforeBuildingTheGroupKeyPredicate() + { + var data = new Mock(); + data.Setup(x => x.GetCompanyDirectoryPagedAsync( + 1, + 100, + null, + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((new List(), 0)); + + var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(0, 500); + + result.Page.Should().Be(1); + result.PageSize.Should().Be(100); + data.VerifyAll(); + } + + [Fact] + public async Task GetVendorTechnicianDirectoryPaged_ForwardsRawPaginationAndCancellationToken() + { + var data = new Mock(); + using var cancellation = new CancellationTokenSource(); + var token = cancellation.Token; + data.Setup(x => x.GetDirectoryPagedAsync( + 2, + 500, + "clayton", + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token)) + .ReturnsAsync((new[] + { + new VendorDirectoryRow { VendorId = 7, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech A" }, + new VendorDirectoryRow { VendorId = 9, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech B" } + }.AsEnumerable().ToList(), 2)); + + var result = await NewService(data).GetVendorTechnicianDirectoryPagedAsync( + 2, + 500, + "clayton", + true, + cancellationToken: token); + + // Legacy pagination semantics: page and pageSize (including 500) pass through + // unclamped, and same-company technicians stay as distinct rows. + result.Page.Should().Be(2); + result.PageSize.Should().Be(500); + result.Items.Should().HaveCount(2); + result.Items.Select(v => v.Id).Should().BeEquivalentTo(new[] { 7, 9 }); + result.Items.Should().OnlyContain(v => v.Technicians.Count == 0); + + data.Verify(x => x.GetDirectoryPagedAsync( + 2, + 500, + "clayton", + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), token), Times.Once); } @@ -657,6 +759,8 @@ public class VendorServiceTests }); data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None); @@ -695,6 +799,8 @@ public class VendorServiceTests }); data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None); @@ -723,6 +829,8 @@ public class VendorServiceTests }); data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None); @@ -750,6 +858,8 @@ public class VendorServiceTests SortOrder = index + 1 }) .ToList()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(true, cts.Token); @@ -799,4 +909,96 @@ public class VendorServiceTests TradeCatalog.CanonicalTrades, opts => opts.WithStrictOrdering()); } + + private static Mock DirectoryDataWithAreas(Action capture) + { + var data = new Mock(); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = 1, Name = "East", NormalizedName = "east" }, + new() { Id = 3, Name = "West", NormalizedName = "west" } + }); + data.Setup(x => x.GetCompanyDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, IReadOnlyCollection?, IReadOnlyCollection?, IReadOnlyCollection?, VendorAreaFilter?, CancellationToken>( + (_, _, _, _, _, _, _, _, filter, _) => capture(filter)) + .ReturnsAsync((new List(), 0)); + return data; + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_ResolvesAreaIdsAndUnassignedSentinel() + { + VendorAreaFilter? captured = null; + var data = DirectoryDataWithAreas(filter => captured = filter); + + await NewService(data).GetVendorCompanyDirectoryPagedAsync( + 1, 10, areas: new[] { " 1 ", "__unassigned__", "", "1" }); + + captured.Should().NotBeNull(); + captured!.AreaIds.Should().Equal(1); + captured.IncludeUnassigned.Should().BeTrue(); + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_UnknownArchivedOrLabelAreaValuesMatchNobody() + { + VendorAreaFilter? captured = null; + var data = DirectoryDataWithAreas(filter => captured = filter); + + // 4 is not in the active catalogue (unknown or archived); "West" is a label. + var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync( + 1, 10, areas: new[] { "4", "West", "-3" }); + + captured.Should().NotBeNull(); + captured!.AreaIds.Should().BeEmpty(); + captured.IncludeUnassigned.Should().BeFalse(); + result.Items.Should().BeEmpty(); + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_BlankAreaValuesApplyNoAreaFilter() + { + VendorAreaFilter? captured = new(); + var data = DirectoryDataWithAreas(filter => captured = filter); + + await NewService(data).GetVendorCompanyDirectoryPagedAsync(1, 10, areas: new[] { " ", "" }); + + captured.Should().BeNull(); + data.Verify(x => x.GetActiveAreasAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetFacetsAsync_AreasComeFromActiveCatalogueOrderedByName() + { + using var cts = new CancellationTokenSource(); + var data = new Mock(); + data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny())) + .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) + .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(cts.Token)) + .ReturnsAsync(new List + { + new() { Id = 3, Name = "West", NormalizedName = "west" }, + new() { Id = 1, Name = "East", NormalizedName = "east" }, + new() { Id = 4, Name = "california", NormalizedName = "california" } + }); + + var facets = await NewService(data).GetFacetsAsync(null, cts.Token); + + facets.Areas.Select(area => (area.Id, area.Name)).Should().Equal( + (4, "california"), (1, "East"), (3, "West")); + data.Verify(x => x.GetActiveAreasAsync(cts.Token), Times.Once); + } } diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs index d02febc..a458ecf 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -23,6 +23,7 @@ public class WorkOrderControllerSearchTests Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), advancedSearch); controller.ControllerContext = new ControllerContext diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceTimestampTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceTimestampTests.cs new file mode 100644 index 0000000..512ad0e --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceTimestampTests.cs @@ -0,0 +1,68 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// The SLA clock and every "created"/"modified" display read these stamps as UTC, so the data layer +/// must never write local server time into them. +/// +public class WorkOrderDataServiceTimestampTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrder NewWorkOrder() => + new() { InternalWONumber = "WO-1", WorkerOrderTitle = "Leak", LocationId = 100 }; + + [Fact] + public async Task AddAsync_KeepsTheCreationTimeTheCallerSet() + { + await using var context = NewContext(); + var createdAt = new DateTime(2026, 9, 25, 14, 0, 0, DateTimeKind.Utc); + var workOrder = NewWorkOrder(); + workOrder.CreatedDate = createdAt; + + var saved = await new WorkOrderDataService(context).AddAsync(workOrder); + + saved.CreatedDate.Should().Be(createdAt); + saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc); + (await context.workOrders.SingleAsync()).CreatedDate.Should().Be(createdAt); + } + + [Fact] + public async Task AddAsync_StampsUtcWhenTheCallerSetNoCreationTime() + { + await using var context = NewContext(); + var before = DateTime.UtcNow; + + var saved = await new WorkOrderDataService(context).AddAsync(NewWorkOrder()); + + saved.CreatedDate.Should().NotBeNull(); + saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc); + saved.CreatedDate.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow); + } + + [Fact] + public async Task UpdateAsync_StampsTheModificationTimeInUtc() + { + await using var context = NewContext(); + var service = new WorkOrderDataService(context); + var saved = await service.AddAsync(NewWorkOrder()); + var before = DateTime.UtcNow; + + await service.UpdateAsync(saved); + + saved.LastModificationTime.Should().NotBeNull(); + saved.LastModificationTime!.Value.Kind.Should().Be(DateTimeKind.Utc); + saved.LastModificationTime.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow); + } +} diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs new file mode 100644 index 0000000..2d88138 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs @@ -0,0 +1,106 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// SH-374: GET api/WorkOrder/GetWorkorderById?id= must read the id from the query string, while +/// GET api/WorkOrder/{id} keeps reading it from the route. Both must return the same work order, +/// and an unknown id keeps the existing "ID not found!" response. +/// +public class WorkOrderGetByIdBindingTests +{ + private const int ExistingId = 374; + private const int UnknownId = 999_999; + + private static ControllerActionDescriptor ActionForTemplate(string relativeTemplate) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMvcCore().AddApplicationPart(typeof(WorkOrderController).Assembly); + + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService().ActionDescriptors.Items + .OfType() + .Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderController)) + .Where(cad => cad.ActionConstraints! + .OfType() + .Any(c => c.HttpMethods.Contains("GET"))) + .Single(cad => string.Equals( + cad.AttributeRouteInfo?.Template?.Trim('/'), + $"api/WorkOrder/{relativeTemplate}", + StringComparison.Ordinal)); + } + + [Theory] + [InlineData("GetWorkorderById", "Query")] + [InlineData("{id:int}", "Path")] + public void Id_binds_from_the_source_its_route_provides(string relativeTemplate, string expectedSource) + { + var action = ActionForTemplate(relativeTemplate); + + var id = action.Parameters.Single(p => p.Name == "id"); + id.BindingInfo.Should().NotBeNull(); + id.BindingInfo!.BindingSource!.Id.Should().Be(expectedSource); + } + + private static (WorkOrderController Controller, WorkOrderDetailReadModel Existing) NewController() + { + var existing = new WorkOrderDetailReadModel { Id = ExistingId, Title = "Leaking roof" }; + var service = new Mock(); + service.Setup(s => s.GetWorkOrderDetailAsync(ExistingId, It.IsAny())).ReturnsAsync(existing); + service.Setup(s => s.GetWorkOrderDetailAsync(UnknownId, It.IsAny())) + .ReturnsAsync((WorkOrderDetailReadModel?)null); + + var controller = new WorkOrderController( + service.Object, + Mock.Of(), + Mock.Of>()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + return (controller, existing); + } + + [Fact] + public async Task Existing_id_returns_the_same_work_order_through_both_routes() + { + var (controller, existing) = NewController(); + + var byQuery = await controller.GetWorkorderById(ExistingId); + var byRoute = await controller.GetWorkorderByRouteId(ExistingId); + + byQuery.Should().BeOfType().Which.Value.Should().BeSameAs(existing); + byRoute.Should().BeOfType().Which.Value.Should().BeSameAs(existing); + } + + [Fact] + public async Task Unknown_id_keeps_the_not_found_response_through_both_routes() + { + var (controller, _) = NewController(); + + foreach (var result in new[] + { + await controller.GetWorkorderById(UnknownId), + await controller.GetWorkorderByRouteId(UnknownId), + }) + { + var body = result.Should().BeOfType().Which.Value + .Should().BeOfType().Subject; + body.Status.Should().Be("Error"); + body.Message.Should().Be("ID not found!"); + } + } +} diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs new file mode 100644 index 0000000..74a5882 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs @@ -0,0 +1,217 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// GET /board/search?ids= shows exactly the listed work orders inside the caller's tenant scope, +/// whatever other filters the board sends alongside. +/// +public class WorkOrderIdsFilterTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx) + => new( + new WorkOrderAdvancedSearchDataService(ctx), + new WorkOrderAccountResolver(new AccountDataService(ctx), new LocationDataService(ctx))); + + private static ClaimsPrincipal AccountUser(int accountId) + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, "admin-1"), + new Claim(ClaimTypes.Role, "Admin") + }, "test")); + + private static WorkOrder Wo( + int id, + DateTime? scheduled, + int accountId = 1, + string? assignTo = "disp-1", + LifecycleStatus? status = LifecycleStatus.Scheduled, + bool? deleted = null, + bool template = false) + => new() + { + Id = id, + AccountId = accountId, + InternalWONumber = $"3000000{id:0000}", + AssignTo = assignTo, + ScheduledDate = scheduled, + LifecycleStatus = status, + IsDeleted = deleted, + istemplate = template + }; + + private static void Seed(ApplicationDbContext ctx) + { + ctx.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 22)), + Wo(2, null), // no schedule date + Wo(3, new DateTime(2025, 1, 6), status: LifecycleStatus.Completed), + Wo(4, new DateTime(2026, 9, 22), assignTo: "disp-2"), + Wo(5, new DateTime(2026, 9, 22)), // not requested + Wo(6, new DateTime(2026, 9, 22), accountId: 2), // another tenant + Wo(7, new DateTime(2026, 9, 22), deleted: true), + Wo(8, new DateTime(2026, 9, 22), template: true)); + ctx.SaveChanges(); + } + + [Fact] + public async Task Ids_ReturnExactlyThoseWorkOrders_IgnoringEveryOtherFilter() + { + await using var ctx = NewContext(); + Seed(ctx); + + var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + Ids = "4,1,2,3", + // Filters the board may still carry; none of them may hide a listed work order. + DatePreset = WorkOrderAdvancedSearchDatePreset.ThisWeek, + Statuses = new List { LifecycleStatus.Scheduled }, + Dispatchers = new List { "disp-1" }, + Search = "no match anywhere", + PageSize = 200 + }, AccountUser(1), "admin-1"); + + result.TotalCount.Should().Be(4); + result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 2, 3, 4 }); + } + + [Fact] + public async Task Ids_NeverWidenTenantOrBaseScope() + { + await using var ctx = NewContext(); + Seed(ctx); + + var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + Ids = "1,6,7,8", + PageSize = 200 + }, AccountUser(1), "admin-1"); + + result.Items.Select(row => row.Id).Should().Equal(1); + result.TotalCount.Should().Be(1); + } + + [Fact] + public async Task Ids_OnlyAnotherTenantsWorkOrders_ReturnsNothing() + { + await using var ctx = NewContext(); + Seed(ctx); + + var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + Ids = "6" + }, AccountUser(1), "admin-1"); + + result.TotalCount.Should().Be(0); + result.Items.Should().BeEmpty(); + } + + [Fact] + public async Task NoIds_KeepsTheExistingFilters() + { + await using var ctx = NewContext(); + Seed(ctx); + + var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2026, 9, 21), + DateTo = new DateOnly(2026, 9, 25), + Dispatchers = new List { "disp-1" }, + PageSize = 200 + }, AccountUser(1), "admin-1"); + + result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 5 }); + } + + [Theory] + [InlineData("1,abc")] + [InlineData("0")] + [InlineData("-3")] + [InlineData("1,,2")] + [InlineData("1.5")] + [InlineData("99999999999")] + public async Task MalformedIds_AreABadRequest(string ids) + { + var controller = NewController(); + + var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids }); + + var badRequest = result.Should().BeOfType().Subject; + badRequest.Value.Should().BeOfType().Which.Message.Should().Contain("ids"); + } + + [Fact] + public async Task MoreThanTheLimit_IsABadRequest() + { + var controller = NewController(); + var ids = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount + 1)); + + var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids }); + + result.Should().BeOfType() + .Which.Value.Should().BeOfType() + .Which.Message.Should().Contain("200"); + } + + [Fact] + public void Parse_DeduplicatesBeforeCountingAndKeepsFirstSeenOrder() + { + var withDuplicates = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount).Concat(new[] { 5, 7 })); + + WorkOrderIdSet.ParseOrThrow(withDuplicates).Should().HaveCount(WorkOrderIdSet.MaxCount); + WorkOrderIdSet.ParseOrThrow(" 9, 3 ,9 ").Should().Equal(9, 3); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Parse_AbsentOrBlank_IsNoFilter(string? ids) + { + WorkOrderIdSet.ParseOrThrow(ids).Should().BeNull(); + } + + private static WorkOrderBoardController NewController() + { + var search = new WorkOrderAdvancedSearchService( + Mock.Of(), + Mock.Of()); + return new WorkOrderBoardController( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + search) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = AccountUser(1) } + } + }; + } +} diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index b377b40..f0ae1af 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -1,5 +1,6 @@ using Api.SeaHavenIndustries.Controllers; using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; @@ -31,7 +32,7 @@ public class WorkOrderMediaControllerTests } [Fact] - public void AddMedia_HasFiftyMegabyteRequestLimit() + public void AddMedia_HasContractRequestLimit() { var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia)); var attribute = Assert.Single( @@ -39,26 +40,293 @@ public class WorkOrderMediaControllerTests candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); var bytes = Assert.Single(attribute.ConstructorArguments); - Assert.Equal(50_000_000L, bytes.Value); + Assert.Equal(110_000_000L, bytes.Value); } [Fact] - public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity() + public void AddMedia_HasContractMultipartBodyLimit() { - var file = new Mock(); - file.SetupGet(candidate => candidate.Length).Returns(50_000_001); + var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia)); + var attribute = Assert.IsType(Assert.Single( + method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true))); + + Assert.Equal(110_000_000L, attribute.MultipartBodyLengthLimit); + } + + [Fact] + public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity() + { + var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); - var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + var result = await controller.AddMedia(1, null, file, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); Assert.Equal("FileTooLarge", error.Code); - Assert.Equal("The uploaded file must not exceed 50 MB.", error.Message); + Assert.Equal("Videos must be 100 MB or smaller.", error.Message); storage.VerifyNoOtherCalls(); } + [Fact] + public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity() + { + var file = OversizeFile(10_000_001, "photo.jpg", "image/jpeg", JpegHeader); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("Photos must be 10 MB or smaller.", error.Message); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddMedia_PhotoDeclaredAsForeignVideoType_IsSizedAsAPhoto() + { + // A .jpg with a foreign video type resolves to image/jpeg for validation, so it must + // also be sized as a photo, not given the 100 MB video allowance. + var file = OversizeFile(60_000_000, "photo.jpg", "video/3gpp", JpegHeader); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("Photos must be 10 MB or smaller.", error.Message); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity() + { + var file = OversizeFile(50_000_001, "report.pdf", "application/pdf", PdfHeader); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("Documents must be 50 MB or smaller.", error.Message); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddMedia_OversizeUnsupportedType_ReportsUnsupportedTypeNotOversizeVideo() + { + var file = OversizeFile(150_000_000, "payload.exe", "application/octet-stream", [0x4D, 0x5A]); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("UnsupportedMediaType", error.Code); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public void ValidateSize_OversizeUnknownKind_UsesTypeNeutralMessage() + { + Assert.Equal( + "Files must be 100 MB or smaller.", + WorkOrderMediaContract.ValidateSize(150_000_000, WorkOrderMediaContract.UploadKind.Unknown)); + } + + private static readonly byte[] FtypHeader = [0, 0, 0, 0x18, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'i', (byte)'s', (byte)'o', (byte)'m']; + private static readonly byte[] JpegHeader = [0xFF, 0xD8, 0xFF, 0xE0]; + private static readonly byte[] PdfHeader = [0x25, 0x50, 0x44, 0x46, 0x2D]; + + /// + /// A file that reports bytes but only backs the 512-byte header the + /// type rules read, so oversize cases run through real signature validation cheaply. + /// + private static FormFile OversizeFile(long length, string fileName, string contentType, byte[] header) + { + var bytes = new byte[512]; + header.CopyTo(bytes, 0); + return new FormFile(new MemoryStream(bytes), 0, length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + private static FormFile VideoFormFile(int size, string fileName, string contentType) + { + var bytes = new byte[size]; + // ISO BMFF ftyp box so the media type rules accept the payload as MP4/MOV. + bytes[4] = (byte)'f'; + bytes[5] = (byte)'t'; + bytes[6] = (byte)'y'; + bytes[7] = (byte)'p'; + bytes[8] = (byte)'i'; + bytes[9] = (byte)'s'; + bytes[10] = (byte)'o'; + bytes[11] = (byte)'m'; + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + private static (Mock Service, Mock Storage) SetupSuccessfulAddMedia() + { + var service = new Mock(MockBehavior.Strict); + service + .Setup(candidate => candidate.EnsureCanMutateMediaAsync( + 1, It.IsAny(), It.IsAny(), It.IsAny(), null)) + .Returns(Task.CompletedTask); + service + .Setup(candidate => candidate.AddMediaAsync( + 1, null, "https://storage.test/stored", It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new WorkOrderMediaFileDto { Id = 5, Url = "https://storage.test/stored" }); + var storage = new Mock(MockBehavior.Strict); + storage + .Setup(candidate => candidate.SaveFileAsync(It.IsAny())) + .ReturnsAsync("https://storage.test/stored"); + return (service, storage); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteMp4_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.mp4", "video/mp4"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var ok = Assert.IsType(result); + Assert.Equal(5, Assert.IsType(ok.Value).Id); + } + + [Fact] + public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity() + { + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + var file = PhoneVideo(durationSeconds: 95, "IMG_0042.MOV", "video/quicktime"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("VideoTooLong", error.Code); + Assert.Equal("Videos must be 90 seconds or shorter.", error.Message); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddMedia_VideoWithinNinetySeconds_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = PhoneVideo(durationSeconds: 60, "IMG_0043.MOV", ""); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + /// ftyp + mdat + moov/mvhd (moov last, as phones write it). + private static FormFile PhoneVideo(uint durationSeconds, string fileName, string contentType) + { + static byte[] Box(string type, byte[] body) + { + var box = new byte[8 + body.Length]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length); + System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4); + body.CopyTo(box, 8); + return box; + } + + var mvhd = new byte[20]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 1000); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 1000); + var bytes = Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0")) + .Concat(Box("mdat", new byte[4096])) + .Concat(Box("moov", Box("mvhd", mvhd))) + .ToArray(); + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + [Fact] + public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.mov", "video/quicktime"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteMovWithEmptyContentType_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.MOV", ""); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteMp4WithOctetStreamContentType_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.mp4", "application/octet-stream"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task AddMedia_HeicPhoto_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var bytes = new byte[512]; + bytes[4] = (byte)'f'; + bytes[5] = (byte)'t'; + bytes[6] = (byte)'y'; + bytes[7] = (byte)'p'; + bytes[8] = (byte)'h'; + bytes[9] = (byte)'e'; + bytes[10] = (byte)'i'; + bytes[11] = (byte)'c'; + var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "capture.heic") + { + Headers = new HeaderDictionary(), + ContentType = "image/heic" + }; + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + [Fact] public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity() { @@ -157,7 +425,8 @@ public class WorkOrderMediaServiceCancellationTests mediaData.Object, new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, - storage.Object); + storage.Object, + new Mock(MockBehavior.Strict).Object); var user = new ClaimsPrincipal(new ClaimsIdentity( new[] { @@ -174,4 +443,37 @@ public class WorkOrderMediaServiceCancellationTests mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once); mediaData.VerifyNoOtherCalls(); } + + [Fact] + public async Task GetMediaContent_ServesHeicAsImageHeic() + { + const string url = "https://example.test/Assets/Images/photo.heic"; + var mediaData = new Mock(MockBehavior.Strict); + mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny())) + .ReturnsAsync(new WorkOrder { Id = 1 }); + mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny())) + .ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url }); + var storage = new Mock(MockBehavior.Strict); + storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3])); + var service = new WorkOrderMediaService( + mediaData.Object, + new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, + storage.Object, + new Mock(MockBehavior.Strict).Object); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, "actor-1"), + new Claim(ClaimTypes.Role, "Admin"), + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll) + }, + "Test")); + + var result = await service.GetMediaContentAsync(1, 10, user, "actor-1"); + result.Content.Dispose(); + + Assert.Equal("image/heic", result.ContentType); + Assert.Equal("photo.heic", result.FileName); + } } diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 804a77b..3428716 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -17,8 +17,8 @@ namespace Api.SeaHavenIndustries.Tests; /// combination is registered more than once by different actions. /// /// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the -/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder, -/// GetWorkorderById) and the two shared controller-level base routes. +/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder) +/// and the two shared controller-level base routes. /// public class WorkOrderRouteContractTests { @@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests /// Baseline public endpoint set (verb + action-relative route) that the original single /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route - /// duplication is collapsed here, so this is the distinct action-relative contract. 54 routes - /// come from 52 actions (Editworkorder and GetWorkorderById each bind two routes). + /// duplication is collapsed here, so this is the distinct action-relative contract. 56 routes + /// come from 55 actions (Editworkorder binds two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { @@ -60,6 +60,7 @@ public class WorkOrderRouteContractTests "GET board/search", "GET completion-templates", "GET completion-templates/{id:int}", + "GET completion-templates/{id:int}/linked-work-orders", "GET lookups/dispatchers", "GET {id:int}", "GET {id:int}/audit", @@ -72,6 +73,7 @@ public class WorkOrderRouteContractTests "PATCH {id:int}/board", "PATCH {id:int}/comments/{commentId:int}", "PATCH {id:int}/media/{mediaId:int}", + "PATCH {id:int}/poc", "POST AddChecklistItem", "POST AddComment", "POST AddCommentJson", diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs index f8068a1..c9465b9 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; using System.Security.Claims; using Xunit; @@ -79,6 +80,75 @@ public sealed class WorkOrderUpliftControllerTests Assert.Equal(12, created.Id); } + [Fact] + public async Task CreateUplift_RequestPermissionDenied_ReturnsExact403Message() + { + var service = new Mock(); + service.Setup(x => x.CreateAsync( + 7, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftForbiddenException(UpliftForbiddenException.RequestUpliftsDeniedMessage)); + + var controller = NewController(service, "Dispatcher"); + var result = await controller.CreateUplift( + 7, + new CreateWorkOrderUpliftRequestDto { Amount = 750m }, + CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + var response = forbidden.Value.Should().BeOfType().Subject; + response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage); + } + + [Fact] + public async Task CreateUplift_UnrelatedForbiddenException_ReturnsSanitized403() + { + var service = new Mock(); + service.Setup(x => x.CreateAsync( + 7, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftForbiddenException("SECRET-internal-tier-detail")); + + var controller = NewController(service, "Dispatcher"); + var result = await controller.CreateUplift( + 7, + new CreateWorkOrderUpliftRequestDto { Amount = 750m }, + CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + var response = forbidden.Value.Should().BeOfType().Subject; + response.Message.Should().NotContain("SECRET-internal-tier-detail"); + response.Message.Should().Contain("reference"); + } + + [Fact] + public async Task CreateUplift_ConcurrentActiveRequest_ReturnsConflict() + { + var service = new Mock(); + service.Setup(x => x.CreateAsync( + 7, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftConflictException()); + + var controller = NewController(service); + var result = await controller.CreateUplift( + 7, + new CreateWorkOrderUpliftRequestDto { Amount = 750m, Notes = "Extra labor" }, + CancellationToken.None); + + var conflict = result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); + conflict.Value.Should().NotBeNull(); + } + [Fact] public async Task CancelUplift_NotFound_Returns404() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs new file mode 100644 index 0000000..9325b3d --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs @@ -0,0 +1,139 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Moq; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Exceptions; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class WorkOrderUpliftServiceConflictTests +{ + [Fact] + public async Task CreateAsync_DifferentWorkOrdersSharingDispatch_MapsConcurrentInsertConflict() + { + // Dispatch 10 is legitimately shared: work order 1 owns it and work order 2 links to + // it through DispatchWorkOrders, so the uplift dispatch resolves to it for both. + var sharedDispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + Status = "Scheduled", + NTEAmount = 1000m, + DispatchWorkOrders = new List + { + new() { DispatchId = 10, WorkOrderId = 2 }, + }, + }; + var upliftData = new Mock(); + upliftData.Setup(x => x.GetUpliftDispatchForWorkOrderAsync( + It.IsIn(1, 2), + 10, + It.IsAny())) + .ReturnsAsync(sharedDispatch); + var bothAtInsert = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var savesArrived = 0; + var winner = 0; + + upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns>, CancellationToken>( + (_, work, cancellationToken) => work(cancellationToken)); + upliftData.Setup(x => x.HasPendingForWorkOrderAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + upliftData.Setup(x => x.HasActiveAsync(10, It.IsAny())) + .ReturnsAsync(false); + upliftData.Setup(x => x.SumAutoApprovedAmountForWorkOrderAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0m); + upliftData.Setup(x => x.StageAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + upliftData.Setup(x => x.SaveChangesAsync(It.IsAny())) + .Returns(async () => + { + if (Interlocked.Increment(ref savesArrived) == 2) + bothAtInsert.TrySetResult(); + + await bothAtInsert.Task.WaitAsync(TimeSpan.FromSeconds(5)); + if (Interlocked.Exchange(ref winner, 1) != 0) + throw new UpliftDispatchConflictException(); + }); + + var detailData = new Mock(); + detailData.Setup(x => x.ExistsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + detailData.Setup(x => x.GetWorkOrderForMediaAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int workOrderId, CancellationToken _, int? _) => new WorkOrder + { + Id = workOrderId, + PrimaryDispatchId = 10, + WorkOrderType = WorkOrderType.PM, + LifecycleStatus = LifecycleStatus.Scheduled, + }); + + var dispatchData = new Mock(); + dispatchData.Setup(x => x.GetByIdAsync(10)) + .ReturnsAsync(sharedDispatch); + dispatchData.Setup(x => x.StageAuditLogAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var accountResolver = new Mock(); + accountResolver.Setup(x => x.ResolveAccountFilter(It.IsAny())).Returns((int?)null); + var userData = new Mock(); + userData.Setup(x => x.GetDisplayNamesByIdsAsync(It.IsAny>())) + .ReturnsAsync(new Dictionary()); + // SH-327: the requester must hold RequestUplifts before the create reaches the insert. + var permissionData = new Mock(); + permissionData.Setup(x => x.GetUserAsync("dispatcher-1", It.IsAny())) + .ReturnsAsync(new TeamPermissionUserData { UserId = "dispatcher-1", RoleName = "Dispatcher" }); + + var service = new WorkOrderUpliftService( + upliftData.Object, + dispatchData.Object, + detailData.Object, + accountResolver.Object, + userData.Object, + permissionData.Object, + new TeamPermissionPolicy(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions + { + UpliftTier1MaxUsd = 2500m, + Tier1Roles = new[] { "Approver" }, + Tier2Roles = new[] { "Manager" }, + }), + new Mock().Object); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") }, + "test")); + + static async Task<(WorkOrderUpliftDto? Result, Exception? Error)> Capture( + Func> create) + { + try + { + return (await create(), null); + } + catch (Exception exception) + { + return (null, exception); + } + } + + var outcomes = await Task.WhenAll( + Capture(() => service.CreateAsync(1, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None)), + Capture(() => service.CreateAsync(2, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None))); + + Assert.Single(outcomes, outcome => outcome.Result != null); + Assert.IsType(Assert.Single(outcomes, outcome => outcome.Error != null).Error); + upliftData.Verify(x => x.HasActiveAsync(10, It.IsAny()), Times.Exactly(2)); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 12fd8fa..c7751b0 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -1,11 +1,15 @@ using Api.SeaHavenIndustries.DTOs; using Api.SeaHavenIndustries.Helper; +using Api.SeaHavenIndustries.Infrastructure; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; +using System.Runtime.CompilerServices; using System.Security.Claims; namespace Api.SeaHavenIndustries.Controllers @@ -33,16 +37,7 @@ namespace Api.SeaHavenIndustries.Controllers var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken); if (result != null) { - return Ok(new - { - token = result.Token, - expiration = result.Expiration, - email = result.Email, - userRoles = result.UserRole, - phoneNumber = result.PhoneNumber, - fullname = result.Fullname, - id = result.Id - }); + return Ok(LoginPayload.From(result)); } return Unauthorized(); @@ -55,20 +50,33 @@ namespace Api.SeaHavenIndustries.Controllers } + [Authorize] [Route("ChangePassword")] [HttpPost] public async Task ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken) { + // [Compare] already rejects this during model validation; the check here keeps the + // unconfirmed password from ever being set if that validation is bypassed. + if (!string.Equals(usermodel.Newpassword, usermodel.Confirmpassword, StringComparison.Ordinal)) + return BadRequest(new Response { Status = "Password confirmation does not match", Message = "Passwords don't match" }); + var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken); - if (succeeded) + var result = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Newpassword, cancellationToken); + return result.Status switch { - return Ok(new Response { Status = "Success ", Message = "Password successfully changed" }); - } - else - return BadRequest(new Response { Status = "Old Password is incorrect" }); + ChangePasswordStatus.Succeeded => + Ok(new Response { Status = "Success ", Message = "Password successfully changed" }), + ChangePasswordStatus.PasswordRejected => + BadRequest(new Response { Status = "Password does not meet requirements", Message = PasswordRequirementsMessage }), + ChangePasswordStatus.Failed => + BadRequest(new Response { Status = "Password not changed", Message = "Your password could not be changed. Try again." }), + _ => BadRequest(new Response { Status = "Old Password is incorrect", Message = "Current password is incorrect" }) + }; } + private const string PasswordRequirementsMessage = + "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."; + [HttpPost] [Route("UpdateProfile")] public async Task UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken) @@ -104,30 +112,44 @@ namespace Api.SeaHavenIndustries.Controllers #region Forget Password Area + public const string ForgetPasswordMessage = + "If that email belongs to an account, a reset code has been sent to it."; + + // Email and code are read only from the JSON body, so they never appear in URLs + // or in proxy and load balancer access logs. [AllowAnonymous] [HttpPost()] [Route("ForgetPassword")] - public async Task ForgetPassword(string Email, CancellationToken cancellationToken) - { - var found = await _authenticationService.ForgetPasswordAsync(Email, cancellationToken); - if (found) - { - return Ok(new Response { Status = "Success ", Message = "Please check your email for code" }); - } - else - { - return BadRequest(new Response { Status = "Error", Message = "No such email is registered" }); - } - } - //need email and code - [AllowAnonymous] - [HttpPost()] - [Route("VerificationCode")] - public async Task VerificationCode(string code, CancellationToken cancellationToken) + [EnableRateLimiting(PasswordResetRateLimiting.ForgetPasswordPolicy)] + public async Task ForgetPassword( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ForgetPasswordRequest_Dto? body, + CancellationToken cancellationToken) { try { - if (await _authenticationService.VerifyCodeAsync(code, cancellationToken)) + await _authenticationService.ForgetPasswordAsync(body?.Email, cancellationToken); + } + catch (Exception ex) + { + // Same answer as success: a failure only a registered address can hit + // must not reveal that the address is registered. + LogResetFailure(ex); + } + + return Ok(new Response { Status = "Success ", Message = ForgetPasswordMessage }); + } + + [AllowAnonymous] + [HttpPost()] + [Route("VerificationCode")] + [EnableRateLimiting(PasswordResetRateLimiting.VerificationCodePolicy)] + public async Task VerificationCode( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] VerificationCode_Dto? body, + CancellationToken cancellationToken) + { + try + { + if (await _authenticationService.VerifyCodeAsync(body?.Email, body?.Code, cancellationToken)) { return Ok(new Response { Status = "Success ", Message = "Code Matched" }); @@ -139,14 +161,15 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + LogResetFailure(ex); + return BadRequest(new Response { Status = "Error", Message = "Code Not Matched" }); } } - // need email, password and code [AllowAnonymous] [HttpPost()] [Route("ResetPassword")] + [EnableRateLimiting(PasswordResetRateLimiting.ResetPasswordPolicy)] public async Task ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken) { try @@ -162,11 +185,21 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - - return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); - + LogResetFailure(ex); + return BadRequest(new Response { Status = "Error", Message = "Your email or code not found please check" }); } } + + // These endpoints answer failures exactly like a wrong code or an unknown email, so + // an error only a registered account can trigger reveals nothing. Exception + // messages here can echo the email or code, so only the type is logged. + private void LogResetFailure(Exception exception, [CallerMemberName] string operation = "") + { + _logger.LogError( + "Password reset {Operation} failed with {ExceptionType}.", + operation, + exception.GetType().FullName); + } #endregion } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 6955cc2..e40c9fd 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -17,17 +18,160 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("Stats")] - public async Task GetStats(CancellationToken cancellationToken) + public async Task GetStats( + [FromQuery] DashboardStatsQueryDTO query, + CancellationToken cancellationToken) { - var stats = await _dashboardService.GetStatsAsync(cancellationToken); + var stats = await _dashboardService.GetStatsAsync(User, query, cancellationToken); return Ok(new { total = stats.Total, open = stats.Open, notDispatched = stats.NotDispatched, - completed = stats.Completed + completed = stats.Completed, + scheduledTomorrow = stats.ScheduledTomorrow, + pendingUplifts = stats.PendingUplifts, + avetaPending = stats.AvetaPending, + unassigned = stats.Unassigned, + breakdown = stats.Breakdown, + dueCount = stats.DueCount, + completedDueCount = stats.CompletedDueCount, + completionRate = stats.CompletionRate, + averageResolutionDays = stats.AverageResolutionDays, + statusDistribution = stats.StatusDistribution }); } + + [HttpGet("Workload")] + public async Task GetWorkload( + [FromQuery] DashboardStatsQueryDTO query, + [FromQuery] int page = 1, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetWorkloadAsync( + User, query, page, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("Performance")] + public async Task GetPerformance( + [FromQuery] DashboardStatsQueryDTO query, + [FromQuery] int page = 1, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetPerformanceAsync( + User, query, page, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("Regions")] + public async Task GetRegions( + [FromQuery] DashboardStatsQueryDTO query, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetRegionsAsync(User, query, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + } + + [HttpGet("Trend")] + public async Task GetTrend( + [FromQuery] DashboardTrendQueryDTO query, + CancellationToken cancellationToken = default) + { + try + { + var trend = await _dashboardService.GetTrendAsync(User, query, cancellationToken); + + return Ok(new + { + granularity = trend.Granularity, + today = trend.Today, + buckets = trend.Buckets.Select(bucket => new + { + date = bucket.Date, + label = bucket.Label, + total = bucket.Total, + open = bucket.Open, + completed = bucket.Completed, + canceled = bucket.Canceled, + overdue = bucket.Overdue, + isCurrent = bucket.IsCurrent + }) + }); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("VendorInsights")] + public async Task GetVendorInsights(CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetVendorInsightsAsync(User, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + } } } diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 5c8bb30..84270e6 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -36,38 +37,49 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("GetLocationList")] - public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) + public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, string? sortBy = null, string? sortDirection = null, CancellationToken cancellationToken = default) { - var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, cancellationToken); - - var data = pagedResult.Items.Select(l => new + try { - Id = l.Id, - Name = l.Name, - Title = l.Title, - Address = l.Address1, - Address1 = l.Address1, - Address2 = l.Address2, - City = l.City, - State = l.State, - ZipCode = l.Zip, - Phone = l.PhoneNumber, - Contact = (string?)null, - ContactEmail = l.Email, - Status = l.Status, - AccountId = l.AccountId - }); + var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection); - var viewModel = new Pagination_DTO + var data = pagedResult.Items.Select(l => new + { + Id = l.Id, + Name = l.Name, + Title = l.Title, + Address = l.Address1, + Address1 = l.Address1, + Address2 = l.Address2, + City = l.City, + State = l.State, + ZipCode = l.Zip, + Phone = l.PhoneNumber, + Contact = l.Contacts?.FirstOrDefault()?.Name, + ContactEmail = l.Email, + Status = l.Status, + AccountId = l.AccountId, + ClientName = l.AccountName, + AccountName = l.AccountName, + Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList() + }); + + var viewModel = new Pagination_DTO + { + Data = data, + PageNumber = page, + PageSize = pageSize, + TotalCount = pagedResult.TotalCount, + TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize) + }; + + return Ok(viewModel); + } + catch (ValidationException vex) { - Data = data, - PageNumber = page, - PageSize = pageSize, - TotalCount = pagedResult.TotalCount, - TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize) - }; - - return Ok(viewModel); + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } } [HttpGet("{id}")] @@ -90,10 +102,14 @@ namespace Api.SeaHavenIndustries.Controllers location.State, ZipCode = location.Zip, Phone = location.PhoneNumber, - Contact = (string?)null, + Contact = location.Contacts?.FirstOrDefault()?.Name, ContactEmail = location.Email, location.Status, - location.AccountId + location.AccountId, + ClientName = location.AccountName, + location.AccountName, + location.Notes, + Contacts = location.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList() }; return Ok(result); @@ -107,6 +123,10 @@ namespace Api.SeaHavenIndustries.Controllers await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), User, cancellationToken); return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" }); } + catch (SiteCodeConflictException) + { + return SiteCodeConflict(); + } catch (ValidationException vex) { var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); @@ -130,6 +150,10 @@ namespace Api.SeaHavenIndustries.Controllers await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), User, cancellationToken); return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" }); } + catch (SiteCodeConflictException) + { + return SiteCodeConflict(); + } catch (ValidationException vex) { var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); @@ -149,17 +173,73 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPatch("{id}/contact-info")] + public async Task UpdateSiteContactInfo(int id, [FromBody] SiteContactInfoInput_DTO model, CancellationToken cancellationToken) + { + try + { + await _locationService.UpdateSiteContactInfoAsync( + id, + new SiteContactInfoRequestDTO { Contacts = MapSiteContacts(model.Contacts), Notes = model.Notes }, + User, + cancellationToken); + return Ok(new DataResponse { Message = "Site Updated Successfully", Status = "200" }); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (UnauthorizedAccessException) + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to edit this site." }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Location not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpGet("{id}/open-work-orders")] + public async Task GetOpenWorkOrders(int id, CancellationToken cancellationToken) + { + try + { + var result = await _locationService.GetOpenWorkOrdersAsync(id, User, cancellationToken); + if (result == null) + return NotFound(new Response { Status = "Error", Message = "Location not found" }); + + return Ok(result); + } + catch (UnauthorizedAccessException) + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to view this site." }); + } + } + [HttpDelete("{id}")] public async Task DeleteLocation(int id, CancellationToken cancellationToken) { try { - var found = await _locationService.DeleteLocationByIdAsync(id, cancellationToken); + var found = await _locationService.DeleteLocationByIdAsync(id, User, cancellationToken); if (!found) return NotFound(new Response { Status = "Error", Message = "Location not found" }); return Ok(new DataResponse { Message = "Location Deleted Successfully", Status = "200" }); } + catch (SiteForbiddenException forbidden) + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = forbidden.Message }); + } + catch (UnauthorizedAccessException) + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to delete this site." }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Location not found" }); @@ -176,6 +256,14 @@ namespace Api.SeaHavenIndustries.Controllers return await DeleteLocation(id, cancellationToken); } + private ObjectResult SiteCodeConflict() => + Conflict(new + { + Status = "Conflict", + Message = SiteCodeConflictException.PublicMessage, + Code = SiteCodeConflictException.ErrorCode + }); + private static LocationCreateRequestDTO MapToCreateRequest(Location_DTO model) { return new LocationCreateRequestDTO @@ -189,7 +277,9 @@ namespace Api.SeaHavenIndustries.Controllers Phone = model.Phone, ContactEmail = model.ContactEmail, Status = model.Status, - AccountId = model.GetAccountId() + AccountId = model.GetAccountId(), + Notes = model.Notes, + Contacts = MapSiteContacts(model.Contacts) }; } @@ -206,8 +296,20 @@ namespace Api.SeaHavenIndustries.Controllers Phone = model.Phone, ContactEmail = model.ContactEmail, Status = model.Status, - AccountId = model.GetAccountId() + AccountId = model.GetAccountId(), + Notes = model.Notes, + Contacts = MapSiteContacts(model.Contacts) }; } + + private static List? MapSiteContacts(List? contacts) + { + if (contacts == null) + return null; + + return contacts + .Select(c => new SiteContactRequestDTO { Id = c.Id, Name = c.Name, Phone = c.Phone }) + .ToList(); + } } } diff --git a/Api.SeaHavenIndustries/Controllers/NotificationsController.cs b/Api.SeaHavenIndustries/Controllers/NotificationsController.cs new file mode 100644 index 0000000..f091119 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/NotificationsController.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/notifications")] + public class NotificationsController : ControllerBase + { + private readonly INotificationFeedService _feedService; + private readonly ISlaBreachAcknowledgementService _slaAcknowledgement; + + public NotificationsController( + INotificationFeedService feedService, + ISlaBreachAcknowledgementService slaAcknowledgement) + { + _feedService = feedService; + _slaAcknowledgement = slaAcknowledgement; + } + + /// + /// Acknowledges the missed response deadline of one work order in the caller's Notification Center. + /// 204 when recorded or already recorded; 404 when the caller's feed does not cover the work order; + /// 409 when its deadline has not been missed. + /// + [HttpPost("sla/{workOrderId:int}/acknowledge")] + public async Task AcknowledgeSlaBreach(int workOrderId, CancellationToken cancellationToken) + { + try + { + var outcome = await _slaAcknowledgement.AcknowledgeAsync(User, workOrderId, cancellationToken); + return outcome switch + { + SlaBreachAcknowledgementOutcome.NotFound => NotFound(new + { + code = "NotFound", + message = "Work order not found." + }), + SlaBreachAcknowledgementOutcome.NotBreached => Conflict(new + { + code = "NotBreached", + message = "This work order has not missed its response deadline." + }), + _ => NoContent() + }; + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + } + + [HttpGet] + public async Task GetFeed(CancellationToken cancellationToken) + { + try + { + return Ok(await _feedService.GetFeedAsync(User, cancellationToken)); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs b/Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs new file mode 100644 index 0000000..fe66268 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs @@ -0,0 +1,110 @@ +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/services")] + public class ServicesRegistryController : Controller + { + private readonly IServicesRegistryService _servicesRegistryService; + + public ServicesRegistryController(IServicesRegistryService servicesRegistryService) + { + _servicesRegistryService = servicesRegistryService; + } + + [HttpGet] + public async Task GetAll([FromQuery] bool? isActive, [FromQuery] WorkOrderType? workOrderType, CancellationToken cancellationToken) + { + try + { + var services = await _servicesRegistryService.GetAllAsync(isActive, workOrderType, cancellationToken); + return Ok(services); + } + catch (ServicesRegistryValidationException ex) + { + return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpGet("{id}")] + public async Task GetById(int id, CancellationToken cancellationToken) + { + var service = await _servicesRegistryService.GetByIdAsync(id, cancellationToken); + if (service == null) + return NotFound(new ServicesRegistryValidationErrorDto { Code = "NotFound", Message = "Service not found." }); + + return Ok(service); + } + + [HttpPost] + public async Task Create([FromBody] ServiceInput input, CancellationToken cancellationToken) + { + try + { + var created = await _servicesRegistryService.CreateAsync(User, input, cancellationToken); + return StatusCode(StatusCodes.Status201Created, created); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden") + { + return MapForbidden(ex); + } + catch (ServicesRegistryValidationException ex) + { + return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPut("{id}")] + public async Task Update(int id, [FromBody] ServiceInput input, CancellationToken cancellationToken) + { + try + { + var updated = await _servicesRegistryService.UpdateAsync(User, id, input, cancellationToken); + return Ok(updated); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden") + { + return MapForbidden(ex); + } + catch (ServicesRegistryValidationException ex) + { + return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPost("{id}/deactivate")] + public async Task Deactivate(int id, CancellationToken cancellationToken) + { + try + { + await _servicesRegistryService.DeactivateAsync(User, id, cancellationToken); + return Ok(new { message = "Service deactivated" }); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden") + { + return MapForbidden(ex); + } + } + + private IActionResult MapForbidden(ServicesRegistryValidationException ex) + { + return StatusCode(StatusCodes.Status403Forbidden, + new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs new file mode 100644 index 0000000..24bae6d --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers; + +[Authorize] +[ApiController] +[Route("api/team-members")] +public sealed class TeamMemberController : ControllerBase +{ + private readonly ITeamMemberService _teamMemberService; + private readonly ITeamPermissionService _permissionService; + private readonly ITeamMemberInviteService _inviteService; + + public TeamMemberController( + ITeamMemberService teamMemberService, + ITeamPermissionService permissionService, + ITeamMemberInviteService inviteService) + { + _teamMemberService = teamMemberService; + _permissionService = permissionService; + _inviteService = inviteService; + } + + [HttpGet("me/permissions")] + public async Task GetMyPermissions(CancellationToken cancellationToken) + { + var result = await _permissionService.GetEffectivePermissionsAsync(User, cancellationToken); + return result.IsSuccess ? Ok(result.Value) : Unauthorized(); + } + + [HttpPost("{userId}/invite")] + public async Task ResendInvite(string userId, CancellationToken cancellationToken) + { + var outcome = await _inviteService.ResendAsync(userId, User, cancellationToken); + if (outcome.Success) + return Ok(new { message = "Invite sent" }); + + return outcome.Error switch + { + "Forbidden" => Forbid(), + "Team member not found." => NotFound(new { message = outcome.Error }), + _ => BadRequest(new { message = outcome.Error }) + }; + } + + [HttpPost] + public async Task Create( + CreateTeamMemberRequestDTO request, + CancellationToken cancellationToken) + { + var outcome = await _teamMemberService.CreateAsync(request, User, cancellationToken); + if (!outcome.Success) + { + if (string.Equals(outcome.Error, "Forbidden", StringComparison.Ordinal)) + return Forbid(); + + return BadRequest(new { message = outcome.Error }); + } + + return Ok(outcome.Member); + } + + [HttpGet("{userId}")] + public async Task Get(string userId, CancellationToken cancellationToken) + { + var outcome = await _teamMemberService.GetAsync(userId, User, cancellationToken); + return ToActionResult(outcome); + } + + [HttpPut("{userId}")] + public async Task Update( + string userId, + UpdateTeamMemberRequestDTO request, + CancellationToken cancellationToken) + { + var outcome = await _teamMemberService.UpdateAsync(userId, request, User, cancellationToken); + return ToActionResult(outcome); + } + + private IActionResult ToActionResult(TeamMemberOperationOutcomeDTO outcome) + { + if (outcome.Success) + return Ok(outcome.Member); + + if (string.Equals(outcome.Error, "Forbidden", StringComparison.Ordinal)) + return Forbid(); + + if (string.Equals(outcome.Error, "Team member not found.", StringComparison.Ordinal)) + return NotFound(new { message = outcome.Error }); + + return BadRequest(new { message = outcome.Error }); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs new file mode 100644 index 0000000..af16db7 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs @@ -0,0 +1,95 @@ +using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Filters; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers; + +/// +/// Anonymous invite registration. The invite token travels only in request bodies, +/// and every failure maps to a fixed public message. +/// +[AllowAnonymous] +[ApiController] +[Route("api/team-member-invites")] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +[TypeFilter(typeof(InviteRegistrationExceptionFilter))] +public sealed class TeamMemberInviteController : ControllerBase +{ + public const string InvalidInviteMessage = + "This invite link is invalid or has expired. Ask your admin to send a new invite."; + + private readonly ITeamMemberRegistrationService _registrationService; + + public TeamMemberInviteController(ITeamMemberRegistrationService registrationService) + { + _registrationService = registrationService; + } + + [HttpPost("resolve")] + public async Task Resolve(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken) + { + var outcome = await _registrationService.ResolveAsync(request.Token, cancellationToken); + return outcome.Status == TeamMemberRegistrationStatus.Ok ? Ok(outcome.Details) : Failure(outcome); + } + + [HttpPost("send-code")] + public async Task SendCode(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken) + { + var outcome = await _registrationService.SendCodeAsync(request.Token, cancellationToken); + return outcome.Status == TeamMemberRegistrationStatus.Ok + ? Ok(new { message = "Code sent" }) + : Failure(outcome); + } + + [HttpPost("verify-code")] + public async Task VerifyCode(VerifyTeamMemberInviteCodeRequestDTO request, CancellationToken cancellationToken) + { + var outcome = await _registrationService.VerifyCodeAsync(request.Token, request.Code, cancellationToken); + return outcome.Status == TeamMemberRegistrationStatus.Ok + ? Ok(new { message = "Email confirmed" }) + : Failure(outcome); + } + + [HttpPost("complete")] + public async Task Complete(CompleteTeamMemberRegistrationRequestDTO request, CancellationToken cancellationToken) + { + var outcome = await _registrationService.CompleteAsync(request, cancellationToken); + return outcome.Status == TeamMemberRegistrationStatus.Ok && outcome.Session is not null + ? Ok(LoginPayload.From(outcome.Session)) + : Failure(outcome); + } + + private IActionResult Failure(TeamMemberRegistrationOutcomeDTO outcome) + { + var (statusCode, code, message) = outcome.Status switch + { + TeamMemberRegistrationStatus.CodeIncorrect => + (StatusCodes.Status400BadRequest, "code_incorrect", "Incorrect code — check your email and try again"), + TeamMemberRegistrationStatus.CodeExpired => + (StatusCodes.Status400BadRequest, "code_expired", "This code has expired — request a new code"), + TeamMemberRegistrationStatus.CodeLocked => + (StatusCodes.Status400BadRequest, "code_locked", "Too many incorrect attempts — request a new code"), + TeamMemberRegistrationStatus.ResendTooSoon => + (StatusCodes.Status429TooManyRequests, "resend_too_soon", "Please wait a moment before requesting another code"), + TeamMemberRegistrationStatus.ResendLimitReached => + (StatusCodes.Status429TooManyRequests, "resend_limit_reached", "Too many codes were requested. Ask your admin to send a new invite."), + TeamMemberRegistrationStatus.CodeDeliveryFailed => + (StatusCodes.Status503ServiceUnavailable, "code_delivery_failed", "We couldn't send the code. Try again in a minute."), + TeamMemberRegistrationStatus.EmailNotConfirmed => + (StatusCodes.Status400BadRequest, "email_not_confirmed", "Confirm your email before finishing registration"), + TeamMemberRegistrationStatus.PasswordRejected => + (StatusCodes.Status400BadRequest, "password_rejected", "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."), + TeamMemberRegistrationStatus.InvalidPhone => + (StatusCodes.Status400BadRequest, "invalid_phone", "Enter a valid phone number"), + _ => (StatusCodes.Status400BadRequest, "invalid_invite", InvalidInviteMessage) + }; + + if (outcome.RetryAfterSeconds is int retryAfter) + Response.Headers.RetryAfter = retryAfter.ToString(System.Globalization.CultureInfo.InvariantCulture); + + return StatusCode(statusCode, new { code, message, retryAfterSeconds = outcome.RetryAfterSeconds }); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs b/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs new file mode 100644 index 0000000..0f6aac0 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs @@ -0,0 +1,66 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers; + +[Authorize] +[ApiController] +[Route("api/User/{userId}/Permissions")] +public sealed class TeamPermissionController : ControllerBase +{ + private readonly ITeamPermissionService _permissionService; + + public TeamPermissionController(ITeamPermissionService permissionService) + { + _permissionService = permissionService; + } + + [HttpGet] + public async Task GetProfile( + string userId, + CancellationToken cancellationToken) + { + var result = await _permissionService.GetProfileAsync(userId, User, cancellationToken); + return ToActionResult(result); + } + + [HttpPut("{permissionKey}")] + public async Task SetOverride( + string userId, + string permissionKey, + [FromBody] SetTeamPermissionOverrideDTO request, + CancellationToken cancellationToken) + { + if (!Enum.IsDefined(request.State)) + return BadRequest(new Response { Status = "Error", Message = "Invalid permission state." }); + + var result = await _permissionService.SetOverrideAsync( + userId, + permissionKey, + request.State, + User, + cancellationToken); + return ToActionResult(result); + } + + private static IActionResult ToActionResult( + TeamPermissionResult result) + { + return result.Status switch + { + TeamPermissionResultStatus.Success => new OkObjectResult(result.Value), + TeamPermissionResultStatus.Unauthorized => new UnauthorizedResult(), + TeamPermissionResultStatus.Forbidden => new ForbidResult(), + TeamPermissionResultStatus.NotFound => new NotFoundObjectResult( + new Response { Status = "Error", Message = "User not found." }), + TeamPermissionResultStatus.InvalidPermissionKey => new BadRequestObjectResult( + new Response { Status = "Error", Message = "Unknown permission key." }), + _ => new BadRequestObjectResult( + new Response { Status = "Error", Message = "Unable to update permissions." }) + }; + } +} diff --git a/Api.SeaHavenIndustries/Controllers/UpliftController.cs b/Api.SeaHavenIndustries/Controllers/UpliftController.cs index cc2d9ca..fcba99f 100644 --- a/Api.SeaHavenIndustries/Controllers/UpliftController.cs +++ b/Api.SeaHavenIndustries/Controllers/UpliftController.cs @@ -127,6 +127,35 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPost("{id:int}/revoke")] + public async Task Revoke( + int id, + [FromBody] DecisionRequest? body, + CancellationToken cancellationToken = default) + { + try + { + var result = await _upliftService.RevokeAsync( + User, + id, + body?.Note ?? string.Empty, + cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") }); + } + catch (UpliftForbiddenException ex) + { + return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to revoke this uplift") }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be revoked") }); + } + } + [HttpGet("can-approve")] public IActionResult CanApprove([FromQuery] int tier) { diff --git a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs index fa5e4fa..1930fe9 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs @@ -2,6 +2,7 @@ using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using FluentValidation; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -70,7 +71,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var roster = await _rosterService.CreateRosterAsync(model, userId, cancellationToken); + var roster = await _rosterService.CreateRosterAsync(model, userId, User, cancellationToken); return Ok(roster); } catch (ValidationException vex) @@ -82,6 +83,12 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorAreaAssignmentForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." }); + } catch (VendorRosterDuplicateNameException dup) { return Conflict(new @@ -109,7 +116,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, cancellationToken); + var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, User, cancellationToken); return Ok(roster); } catch (ValidationException vex) @@ -121,6 +128,12 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorAreaAssignmentForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Vendor company not found" }); @@ -164,7 +177,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, cancellationToken); + var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, User, cancellationToken); return Ok(roster); } catch (ValidationException vex) @@ -176,6 +189,12 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorAreaAssignmentForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Vendor company not found" }); diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 1051fcf..2db1eba 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -26,6 +26,9 @@ namespace Api.SeaHavenIndustries.Controllers _logger = logger; } + // SH-198 legacy per-technician directory contract. Existing consumers depend + // on one row per technician (no grouped Technicians) and on raw pagination + // semantics, including pageSize values above the grouped-directory bound. [HttpGet("GetVendorList")] public async Task GetVendorList( string? search = "", @@ -38,7 +41,7 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] string[]? jobBuckets = null, CancellationToken cancellationToken = default) { - var pagedResult = await _vendorService.GetVendorDirectoryPagedAsync( + var pagedResult = await _vendorService.GetVendorTechnicianDirectoryPagedAsync( page, pageSize, search, @@ -80,6 +83,79 @@ namespace Api.SeaHavenIndustries.Controllers }); } + // SH-281 company-grouped directory: one row per company with the full + // status-eligible technician roster nested under Technicians and bounded + // page sizes. Top-level Email is the company email; technician emails stay + // on the nested rows. + [HttpGet("GetVendorDirectoryList")] + public async Task GetVendorDirectoryList( + string? search = "", + int page = 1, + int pageSize = 10, + bool? isActive = true, + [FromQuery] string[]? companies = null, + [FromQuery] string[]? trades = null, + [FromQuery] string[]? locations = null, + [FromQuery] string[]? jobBuckets = null, + [FromQuery] string[]? areas = null, + CancellationToken cancellationToken = default) + { + var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync( + page, + pageSize, + search, + isActive, + companies, + trades, + locations, + jobBuckets, + areas, + cancellationToken); + + var data = pagedResult.Items.Select(v => new + { + v.Id, + CompanyName = v.CompanyName, + v.CompanyId, + v.ContactName, + v.Email, + v.Phone, + v.CompanyPhone, + v.PreferredContact, + v.Address, + v.City, + v.State, + Zip = v.Zipcode, + v.TradeSpecialties, + v.GoogleMapsUrl, + v.Notes, + v.IsActive, + v.TotalJobs, + v.AreaId, + v.AreaName, + Technicians = v.Technicians.Select(t => new + { + t.Id, + t.ContactName, + t.Email, + t.Phone, + t.PreferredContact, + t.TradeSpecialties, + t.IsActive, + t.TotalJobs + }) + }); + + return Ok(new Pagination_DTO + { + Data = data, + PageNumber = pagedResult.Page, + PageSize = pagedResult.PageSize, + TotalCount = pagedResult.TotalCount, + TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pagedResult.PageSize) + }); + } + [HttpGet("{id}")] [HttpGet("GetById")] public async Task GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId) diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 39dd90b..41d024c 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -4,6 +4,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -217,6 +219,10 @@ namespace Api.SeaHavenIndustries.Controllers var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, body?.RequestKey, body?.EvidenceDocumentId, cancellationToken); return Ok(new DataResponse { Status = "Success", Data = result }); } + catch (UpliftConflictException ex) + { + return Conflict(new Response { Status = "Error", Message = ex.Message }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); @@ -294,7 +300,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("dispatches/{id:int}/completion-documents")] [HttpPost("dispatches/{id:int}/documents")] - [RequestSizeLimit(10_000_000)] + [RequestSizeLimit(WorkOrderMediaContract.MaxUploadRequestBytes)] public async Task UploadCompletionDocument( int id, [FromForm] IFormFile file, diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs index f991f56..37e39ea 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs @@ -24,6 +24,7 @@ namespace Api.SeaHavenIndustries.Controllers private readonly IWorkOrderBoardUpdateService _boardUpdateService; private readonly IWorkOrderBoardCreateService _boardCreateService; private readonly IWorkOrderBoardCancelService _boardCancelService; + private readonly IWorkOrderPocService _pocService; private readonly IWorkOrderAdvancedSearchService _advancedSearchService; public WorkOrderBoardController( @@ -31,12 +32,14 @@ namespace Api.SeaHavenIndustries.Controllers IWorkOrderBoardUpdateService boardUpdateService, IWorkOrderBoardCreateService boardCreateService, IWorkOrderBoardCancelService boardCancelService, + IWorkOrderPocService pocService, IWorkOrderAdvancedSearchService advancedSearchService) { _workOrderBoardService = workOrderBoardService; _boardUpdateService = boardUpdateService; _boardCreateService = boardCreateService; _boardCancelService = boardCancelService; + _pocService = pocService; _advancedSearchService = advancedSearchService; } @@ -165,6 +168,56 @@ namespace Api.SeaHavenIndustries.Controllers } } + /// + /// SH-379: replaces the WO-level POC (name, phone, notes) with audit entries. + /// Blank name+phone clears the override so the work order follows the Site. + /// + [HttpPatch("{id:int}/poc")] + public async Task UpdateWorkOrderPoc(int id, [FromBody] WorkOrderPocUpdateRequestDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var row = await _pocService.UpdatePocAsync(id, request, User, actorId); + return Ok(row); + } + catch (WorkOrderBoardConcurrencyException ex) + { + return Conflict(new WorkOrderBoardConflictDto + { + CurrentState = ex.CurrentState + }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (ArgumentException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } + } + [HttpPost("board")] public async Task CreateBoardWorkOrder( [FromBody] WorkOrderBoardCreateRequestDto request, diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs index 655892c..5631705 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs @@ -18,83 +18,111 @@ namespace Api.SeaHavenIndustries.Controllers public class WorkOrderCompletionController : Controller { private readonly IWorkOrderCompletionService _workOrderCompletionService; + private readonly ICompletionDocTemplateService _completionDocTemplateService; private readonly IFileStoragePort _fileStorage; public WorkOrderCompletionController( IWorkOrderCompletionService workOrderCompletionService, + ICompletionDocTemplateService completionDocTemplateService, IFileStoragePort fileStorage) { _workOrderCompletionService = workOrderCompletionService; + _completionDocTemplateService = completionDocTemplateService; _fileStorage = fileStorage; } [HttpGet("completion-templates")] public async Task GetCompletionTemplates( + [FromQuery] string? search = null, [FromQuery] string? serviceKey = null, - [FromQuery] WorkOrderType? workOrderType = null) + [FromQuery] WorkOrderType? workOrderType = null, + CancellationToken cancellationToken = default) { - var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType); + var templates = await _completionDocTemplateService.ListAsync(search, serviceKey, workOrderType, cancellationToken); return Ok(templates); } [HttpGet("completion-templates/{id:int}")] - public async Task GetCompletionTemplate(int id) + public async Task GetCompletionTemplate(int id, CancellationToken cancellationToken) { - var template = await _workOrderCompletionService.GetTemplateByIdAsync(id); + var template = await _completionDocTemplateService.GetAsync(id, cancellationToken); if (template == null) return NotFound(new Response { Status = "Error", Message = "Template not found." }); return Ok(template); } - [Authorize(Roles = "Admin")] - [HttpPost("completion-templates")] - public async Task CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request) + [HttpGet("completion-templates/{id:int}/linked-work-orders")] + public async Task GetCompletionTemplateLinkedWorkOrders(int id, CancellationToken cancellationToken) { try { - var created = await _workOrderCompletionService.CreateTemplateAsync(request); + var linked = await _completionDocTemplateService.GetLinkedWorkOrdersAsync(User, id, cancellationToken); + return Ok(linked); + } + catch (WorkOrderBoardValidationException ex) + { + return MapTemplateError(ex); + } + } + + [HttpPost("completion-templates")] + public async Task CreateCompletionTemplate( + [FromBody] CompletionDocTemplateCreateDto request, + CancellationToken cancellationToken) + { + try + { + var created = await _completionDocTemplateService.CreateAsync(User, request, cancellationToken); return Ok(created); } catch (WorkOrderBoardValidationException ex) { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + return MapTemplateError(ex); } } - [Authorize(Roles = "Admin")] [HttpPut("completion-templates/{id:int}")] - public async Task UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request) + public async Task UpdateCompletionTemplate( + int id, + [FromBody] CompletionDocTemplateCreateDto request, + CancellationToken cancellationToken) { try { - var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request); + var updated = await _completionDocTemplateService.UpdateAsync(User, id, request, cancellationToken); return Ok(updated); } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") - { - return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } catch (WorkOrderBoardValidationException ex) { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + return MapTemplateError(ex); } } - [Authorize(Roles = "Admin")] [HttpDelete("completion-templates/{id:int}")] - public async Task DeleteCompletionTemplate(int id) + public async Task DeleteCompletionTemplate(int id, CancellationToken cancellationToken) { try { - await _workOrderCompletionService.DeleteTemplateAsync(id); + await _completionDocTemplateService.DeleteAsync(User, id, cancellationToken); return NoContent(); } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + catch (WorkOrderBoardValidationException ex) { - return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + return MapTemplateError(ex); } } + private IActionResult MapTemplateError(WorkOrderBoardValidationException ex) + { + var body = new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }; + return ex.Code switch + { + "Forbidden" => StatusCode(StatusCodes.Status403Forbidden, body), + "NotFound" => NotFound(body), + _ => UnprocessableEntity(body) + }; + } + [HttpPost("{id:int}/completion-doc")] [RequestSizeLimit(50_000_000)] public async Task UploadCompletionDoc( diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index c7928de..9c73034 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -208,9 +208,15 @@ namespace Api.SeaHavenIndustries.Controllers } } - [HttpGet("{id:int}")] + // SH-374: two actions, one per route, so each binds id from the source its route provides. + // A single action carrying both routes inferred id as [FromRoute] and the query route got 0. [HttpGet("GetWorkorderById")] - public async Task GetWorkorderById(int id) + public Task GetWorkorderById([FromQuery] int id) => GetWorkorderDetail(id); + + [HttpGet("{id:int}")] + public Task GetWorkorderByRouteId([FromRoute] int id) => GetWorkorderDetail(id); + + private async Task GetWorkorderDetail(int id) { try { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index eb99b1b..7d032fb 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -167,13 +167,20 @@ namespace Api.SeaHavenIndustries.Controllers return NotFound(new Response { Status = "Error", Message = "Work order not found." }); return Ok(new DataResponse { Status = "Success", Data = created }); } + catch (UpliftConflictException ex) + { + return Conflict(new Response { Status = "Error", Message = ex.Message }); + } catch (KeyNotFoundException ex) { return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") }); } catch (UpliftForbiddenException ex) { - return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action") }); + var message = ex.Message == UpliftForbiddenException.RequestUpliftsDeniedMessage + ? UpliftForbiddenException.RequestUpliftsDeniedMessage + : _logger.Sanitize(ex, "You are not authorized to perform this action"); + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = message }); } catch (InvalidOperationException ex) { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 62f81df..32122ca 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -17,7 +17,7 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/workorders")] public class WorkOrderMediaController : Controller { - public const long MaxUploadBytes = 50_000_000; + public const long MaxUploadBytes = WorkOrderMediaContract.MaxUploadRequestBytes; private readonly IWorkOrderMediaService _workOrderMediaService; private readonly IFileStoragePort _fileStorage; @@ -75,6 +75,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("{id:int}/media")] [RequestSizeLimit(MaxUploadBytes)] + [RequestFormLimits(MultipartBodyLengthLimit = MaxUploadBytes)] public async Task AddMedia( int id, [FromForm] WorkOrderMediaCategory? category, @@ -87,14 +88,28 @@ namespace Api.SeaHavenIndustries.Controllers string? fileUrl = null; try { - if (file.Length > MaxUploadBytes) + // Type first, so an unsupported file always reports UnsupportedMediaType instead + // of being sized under a kind it does not have. + WorkOrderMediaFileRules.EnsureAllowed(file, category); + + // Media contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). + // Classify by the same resolved type EnsureAllowed validates against. + var sizeMessage = WorkOrderMediaContract.ValidateSize( + WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length); + if (sizeMessage != null) { - throw new WorkOrderBoardValidationException( - "FileTooLarge", - "The uploaded file must not exceed 50 MB."); + throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage); } - WorkOrderMediaFileRules.EnsureAllowed(file, category); + if (WorkOrderMediaContract.ResolveKind( + WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName) + == WorkOrderMediaContract.UploadKind.Video) + { + using var content = file.OpenReadStream(); + var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content); + if (durationMessage != null) + throw new WorkOrderBoardValidationException("VideoTooLong", durationMessage); + } var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category); diff --git a/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs b/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs index d7d3d63..b526bad 100644 --- a/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs @@ -16,6 +16,8 @@ namespace Api.SeaHavenIndustries.DTOs public string? ContactEmail { get; set; } public string? Status { get; set; } public string? AccountId { get; set; } + public string? Notes { get; set; } + public List? Contacts { get; set; } public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId); diff --git a/Api.SeaHavenIndustries/DTOs/ForgetPassword_Dto.cs b/Api.SeaHavenIndustries/DTOs/ForgetPassword_Dto.cs index 3ad9bae..1a06931 100644 --- a/Api.SeaHavenIndustries/DTOs/ForgetPassword_Dto.cs +++ b/Api.SeaHavenIndustries/DTOs/ForgetPassword_Dto.cs @@ -4,9 +4,20 @@ namespace Api.SeaHavenIndustries.DTOs { public class ForgetPassword_Dto { - public string Email { get; set; } + public string? Email { get; set; } [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] public string? Password { get; set; } public string? Code { get; set; } } + + public class ForgetPasswordRequest_Dto + { + public string? Email { get; set; } + } + + public class VerificationCode_Dto + { + public string? Email { get; set; } + public string? Code { get; set; } + } } diff --git a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs index 68aa518..4fd7f72 100644 --- a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs @@ -3,6 +3,21 @@ using SeaHaven.Services.DTOs; namespace Api.SeaHavenIndustries.DTOs { + // SH-138: additive site-contact write row (id optional on writes) + public class SiteContactInput_DTO + { + public int? Id { get; set; } + public string? Name { get; set; } + public string? Phone { get; set; } + } + + /// Contacts and notes edited from the work-order Site dialog. + public class SiteContactInfoInput_DTO + { + public List? Contacts { get; set; } + public string? Notes { get; set; } + } + public class Location_DTO { public string? Title { get; set; } @@ -16,6 +31,8 @@ namespace Api.SeaHavenIndustries.DTOs public string? ContactEmail { get; set; } public string? Status { get; set; } public string? AccountId { get; set; } + public string? Notes { get; set; } + public List? Contacts { get; set; } public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId); diff --git a/Api.SeaHavenIndustries/DTOs/LoginPayload.cs b/Api.SeaHavenIndustries/DTOs/LoginPayload.cs new file mode 100644 index 0000000..9e13416 --- /dev/null +++ b/Api.SeaHavenIndustries/DTOs/LoginPayload.cs @@ -0,0 +1,24 @@ +using SeaHaven.Services.DTOs; + +namespace Api.SeaHavenIndustries.DTOs +{ + /// The session payload the web app stores after sign-in. + public static class LoginPayload + { + public static object From(LoginResultDTO result) + { + ArgumentNullException.ThrowIfNull(result); + + return new + { + token = result.Token, + expiration = result.Expiration, + email = result.Email, + userRoles = result.UserRole, + phoneNumber = result.PhoneNumber, + fullname = result.Fullname, + id = result.Id + }; + } + } +} diff --git a/Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs b/Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs new file mode 100644 index 0000000..93b9b00 --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; + +namespace Api.SeaHavenIndustries.Filters +{ + /// + /// Anonymous invite endpoints never echo an exception: controller-scoped exception + /// filters run before the global ones, so no message, stack or SQL reaches the caller. + /// Cancellation is left to the host. + /// + public sealed class InviteRegistrationExceptionFilter : IExceptionFilter + { + public const string Message = "Something went wrong. Try again in a minute."; + + private readonly ILogger _logger; + + public InviteRegistrationExceptionFilter(ILogger logger) + { + _logger = logger; + } + + public void OnException(ExceptionContext context) + { + if (context.Exception is OperationCanceledException) + return; + + _logger.LogError(context.Exception, "Invite registration request failed."); + + context.Result = new ObjectResult(new { code = "server_error", message = Message, retryAfterSeconds = (int?)null }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + context.ExceptionHandled = true; + } + } +} diff --git a/Api.SeaHavenIndustries/Helper/SendMessage.cs b/Api.SeaHavenIndustries/Helper/SendMessage.cs index 08db640..5bab7ee 100644 --- a/Api.SeaHavenIndustries/Helper/SendMessage.cs +++ b/Api.SeaHavenIndustries/Helper/SendMessage.cs @@ -9,14 +9,37 @@ namespace Api.SeaHavenIndustries.Helper public class SendMessage : IEmailSender { private IConfiguration _configuration; + private readonly ILogger _logger; //string keysapi = ""; - public SendMessage(IConfiguration configuration) + public SendMessage(IConfiguration configuration, ILogger logger) { _configuration = configuration; + _logger = logger; //keysapi = _configuration.GetValue("SendGrid:ApiKey"); } + protected virtual ISendGridClient CreateClient(string? apiKey) => new SendGridClient(apiKey); + + /// + /// SendGrid reports a rejected message through the status code, not an exception. + /// Only the status is logged: never the recipient, subject or response body. + /// + private bool Delivered(Response response) + { + if (response.IsSuccessStatusCode) + return true; + + _logger.LogWarning("SendGrid rejected an email with status {StatusCode}.", (int)response.StatusCode); + return false; + } + + private bool Failed(Exception ex) + { + _logger.LogWarning("SendGrid email send failed with {ExceptionType}.", ex.GetType().Name); + return false; + } + public async Task SendEMail(string emailTo, string subject, string body) { try @@ -41,7 +64,7 @@ namespace Api.SeaHavenIndustries.Helper var apiKey = _configuration.GetValue("SendGrid:ApiKey"); //var apiKey = ""; - var client = new SendGridClient(apiKey); + var client = CreateClient(apiKey); var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries"); //var subject = "Sending with SendGrid is Fun"; var to = new EmailAddress(emailTo, ""); @@ -51,12 +74,11 @@ namespace Api.SeaHavenIndustries.Helper var htmlContent = body; var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent); var response = await client.SendEmailAsync(msg); - var dd = response.Body.ReadAsStringAsync(); - return true; + return Delivered(response); } catch (Exception ex) { - return false; + return Failed(ex); } } public async Task SendEmailAsync(string emailTo, string subject, string htmlBody) @@ -69,7 +91,7 @@ namespace Api.SeaHavenIndustries.Helper var apiKey = _configuration.GetValue("SendGrid:ApiKey"); //var apiKey = ""; - var client = new SendGridClient(apiKey); + var client = CreateClient(apiKey); var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries"); //var subject = "Sending with SendGrid is Fun"; var to = new EmailAddress(emailTo, ""); @@ -92,13 +114,11 @@ namespace Api.SeaHavenIndustries.Helper msg.Attachments = new List { attachment }; var response = await client.SendEmailAsync(msg); - - var dd = response.Body.ReadAsStringAsync(); - return true; + return Delivered(response); } catch (Exception ex) { - return false; + return Failed(ex); } } public async Task SendDispatchEmail(string emailTo, string subject, string htmlBody, string? replyTo) @@ -106,7 +126,7 @@ namespace Api.SeaHavenIndustries.Helper try { var apiKey = _configuration.GetValue("SendGrid:ApiKey"); - var client = new SendGridClient(apiKey); + var client = CreateClient(apiKey); var from = new EmailAddress("tech@seahavenind.com", "Sea Haven Industries"); var to = new EmailAddress(emailTo, ""); @@ -116,11 +136,11 @@ namespace Api.SeaHavenIndustries.Helper msg.SetReplyTo(new EmailAddress(replyTo)); var response = await client.SendEmailAsync(msg); - return true; + return Delivered(response); } catch (Exception ex) { - return false; + return Failed(ex); } } } diff --git a/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs b/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs new file mode 100644 index 0000000..172f678 --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs @@ -0,0 +1,129 @@ +using System.Threading.Channels; +using Api.SeaHavenIndustries.Observability; +using SeaHaven.Services.Interfaces; +using Sentry; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public static class PasswordResetEmailDelivery + { + /// + /// Registers the process-wide reset email queue and the background service that + /// drains it. Both must be singletons: the request and the sender share one channel. + /// + public static IServiceCollection AddPasswordResetEmailDelivery(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddHostedService(); + return services; + } + } + + public sealed record PasswordResetEmail(string EmailTo, string Subject, string Body); + + public sealed class PasswordResetEmailChannel : IPasswordResetEmailQueue + { + public const int Capacity = 1000; + + private readonly Channel _channel = Channel.CreateBounded( + new BoundedChannelOptions(Capacity) + { + // Wait, not DropWrite: with DropWrite, TryWrite reports success and discards + // the email, so a full queue would still count the request. TryWrite never + // blocks; under Wait it returns false when the queue is full. + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true + }); + private readonly ILogger _logger; + private int _pending; + + public PasswordResetEmailChannel(ILogger logger) + { + _logger = logger; + } + + public ChannelReader Reader => _channel.Reader; + + /// Emails accepted and not yet handed to the mail provider. + public int Pending => Volatile.Read(ref _pending); + + public bool TryEnqueue(string emailTo, string subject, string body) + { + Interlocked.Increment(ref _pending); + if (_channel.Writer.TryWrite(new PasswordResetEmail(emailTo, subject, body))) + return true; + + Interlocked.Decrement(ref _pending); + _logger.LogWarning("Password reset email queue is full; an email was dropped."); + return false; + } + + public void MarkHandled() => Interlocked.Decrement(ref _pending); + } + + public sealed class PasswordResetEmailSenderHostedService : BackgroundService + { + private readonly PasswordResetEmailChannel _channel; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly IHub _sentryHub; + + public PasswordResetEmailSenderHostedService( + PasswordResetEmailChannel channel, + IServiceScopeFactory scopeFactory, + ILogger logger, + IHub sentryHub) + { + _channel = channel; + _scopeFactory = scopeFactory; + _logger = logger; + _sentryHub = sentryHub; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var email in _channel.Reader.ReadAllAsync(stoppingToken)) + { + using var transaction = SentryObservability.BeginBackgroundTransaction( + _sentryHub, + "auth.password-reset-email", + $"{nameof(PasswordResetEmailSenderHostedService)}.{nameof(SendAsync)}"); + try + { + if (await SendAsync(email)) + transaction.FinishOk(); + else + transaction.FinishError(new InvalidOperationException("The mail provider did not accept the password reset email.")); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + transaction.FinishCancelled(); + throw; + } + catch (Exception ex) + { + // The message can echo the recipient or the body, so only the type is logged. + _logger.LogError("Password reset email failed with {ExceptionType}.", ex.GetType().FullName); + transaction.FinishError(ex); + } + finally + { + _channel.MarkHandled(); + } + } + } + + /// True when the mail provider accepted the email. + private async Task SendAsync(PasswordResetEmail email) + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var sender = scope.ServiceProvider.GetRequiredService(); + if (await sender.SendEmailAsync(email.EmailTo, email.Subject, email.Body)) + return true; + + _logger.LogWarning("Password reset email was not accepted by the mail provider."); + return false; + } + } +} diff --git a/Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs b/Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs new file mode 100644 index 0000000..752e494 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs @@ -0,0 +1,24 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; + +namespace Api.SeaHavenIndustries.Infrastructure +{ + public static class IdentityRegistration + { + /// + /// Registers ASP.NET Identity for the API with the shared password policy. + /// Program.cs and the behavior tests both compose Identity through this + /// method so the rule under test is the rule that runs. + /// + public static IdentityBuilder AddSeaHavenIdentity(this IServiceCollection services) + { + return services.AddIdentity(options => + { + options.User.RequireUniqueEmail = false; + IdentityPasswordPolicy.Apply(options.Password); + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + } + } +} diff --git a/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs b/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs new file mode 100644 index 0000000..976a6b2 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs @@ -0,0 +1,67 @@ +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Infrastructure +{ + public static class JwtAuthenticationRegistration + { + /// + /// Registers bearer-token authentication as the default scheme. Program.cs and the + /// behavior tests both compose authentication through this method so the token + /// rules under test are the rules that run. + /// + public static IServiceCollection AddSeaHavenJwtAuthentication(this IServiceCollection services, IConfiguration configuration) + { + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.SaveToken = true; + options.RequireHttpsMetadata = false; + options.TokenValidationParameters = new TokenValidationParameters() + { + ValidateIssuer = true, + ValidateAudience = true, + ValidAudience = configuration["JWT:ValidAudience"], + ValidIssuer = configuration["JWT:ValidIssuer"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes( + configuration["JWT:Secret"] + ?? throw new InvalidOperationException("JWT:Secret configuration is required"))) + }; + options.Events = new JwtBearerEvents + { + OnTokenValidated = RejectEndedSessionAsync + }; + }); + + return services; + } + + /// + /// Refuses a correctly signed token whose session stamp no longer matches the + /// account: the password was reset or changed, or the account was deactivated or + /// deleted, after the token was issued. A token without a stamp is refused too. + /// + private static async Task RejectEndedSessionAsync(TokenValidatedContext context) + { + var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier); + var claimValue = context.Principal?.FindFirstValue(SeaHavenClaimTypes.SessionStamp); + var sessions = context.HttpContext.RequestServices.GetRequiredService(); + + if (string.IsNullOrEmpty(userId) + || !await sessions.IsCurrentAsync(userId, claimValue, context.HttpContext.RequestAborted)) + { + // The handler logs this text; it names no account, token or stamp. + context.Fail("The session has ended."); + } + } + } +} diff --git a/Api.SeaHavenIndustries/Infrastructure/PasswordResetRateLimiting.cs b/Api.SeaHavenIndustries/Infrastructure/PasswordResetRateLimiting.cs new file mode 100644 index 0000000..a9b25d5 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/PasswordResetRateLimiting.cs @@ -0,0 +1,84 @@ +using System.Net; +using System.Threading.RateLimiting; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; + +namespace Api.SeaHavenIndustries.Infrastructure; + +/// +/// Per-client limits on the anonymous password reset endpoints, plus the +/// forwarded-header trust that makes "per client" mean the caller and not the proxy. +/// +public static class PasswordResetRateLimiting +{ + public const string ForgetPasswordPolicy = "password-reset-request"; + public const string VerificationCodePolicy = "password-reset-verify"; + public const string ResetPasswordPolicy = "password-reset-confirm"; + + public const int PermitLimit = 10; + public static readonly TimeSpan Window = TimeSpan.FromMinutes(15); + + public const string RejectedMessage = "Too many requests. Please try again later."; + + /// + /// The API runs on Elastic Beanstalk behind an application load balancer and the + /// instance's nginx, so every request reaches Kestrel from loopback. X-Forwarded-For + /// is read right to left through loopback and private (VPC) hops only; the first + /// public address is the client. Entries a caller writes further left are ignored. + /// + public static IServiceCollection AddPasswordResetRateLimiting(this IServiceCollection services) + { + services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor; + options.ForwardLimit = null; + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("127.0.0.0"), 8)); + options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("10.0.0.0"), 8)); + options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("172.16.0.0"), 12)); + options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("192.168.0.0"), 16)); + options.KnownProxies.Add(IPAddress.IPv6Loopback); + }); + + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = async (context, cancellationToken) => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.HttpContext.Response.WriteAsJsonAsync( + new Response { Status = "Error", Message = RejectedMessage }, + cancellationToken); + }; + + AddPerClientPolicy(options, ForgetPasswordPolicy); + AddPerClientPolicy(options, VerificationCodePolicy); + AddPerClientPolicy(options, ResetPasswordPolicy); + }); + + return services; + } + + public static string ClientPartitionKey(HttpContext context) + { + var address = context.Connection.RemoteIpAddress; + if (address == null) + return "unknown"; + return (address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address).ToString(); + } + + private static void AddPerClientPolicy(RateLimiterOptions options, string policyName) + { + options.AddPolicy(policyName, context => RateLimitPartition.GetFixedWindowLimiter( + ClientPartitionKey(context), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = PermitLimit, + Window = Window, + QueueLimit = 0, + AutoReplenishment = true + })); + } +} diff --git a/Api.SeaHavenIndustries/Observability/SentryObservability.cs b/Api.SeaHavenIndustries/Observability/SentryObservability.cs index 0a1ec56..f58f8f7 100644 --- a/Api.SeaHavenIndustries/Observability/SentryObservability.cs +++ b/Api.SeaHavenIndustries/Observability/SentryObservability.cs @@ -29,21 +29,46 @@ public static class SentryObservability .GetCustomAttribute()? .InformationalVersion; - return IsReleaseInformationalVersion(informationalVersion) - ? informationalVersion![ReleasePrefix.Length..] - : null; + return ResolveCommitSha(informationalVersion); } public static string ResolveRelease(string? informationalVersion) => - IsReleaseInformationalVersion(informationalVersion) - ? informationalVersion! + TryResolveCommitSha(informationalVersion, out var commitSha) + ? ReleasePrefix + commitSha : LocalDevelopmentRelease; + public static string? ResolveCommitSha(string? informationalVersion) => + TryResolveCommitSha(informationalVersion, out var commitSha) + ? commitSha + : null; + public static bool IsReleaseInformationalVersion(string? value) => - value is not null - && value.StartsWith(ReleasePrefix, StringComparison.Ordinal) - && value.Length == ReleasePrefix.Length + CommitShaLength - && value[ReleasePrefix.Length..].All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'); + TryResolveCommitSha(value, out _); + + private static bool TryResolveCommitSha(string? value, out string? commitSha) + { + commitSha = null; + + if (value is null || !value.StartsWith(ReleasePrefix, StringComparison.Ordinal)) + return false; + + var body = value[ReleasePrefix.Length..]; + var suffixSeparatorIndex = body.IndexOf('+'); + var sha = suffixSeparatorIndex < 0 ? body : body[..suffixSeparatorIndex]; + + if (sha.Length != CommitShaLength || !sha.All(IsCommitShaHexDigit)) + return false; + + if (suffixSeparatorIndex >= 0 + && !string.Equals(body[(suffixSeparatorIndex + 1)..], sha, StringComparison.OrdinalIgnoreCase)) + return false; + + commitSha = sha.ToLowerInvariant(); + return true; + } + + private static bool IsCommitShaHexDigit(char c) => + c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'; public static void ConfigureRequest(IHub hub, HttpContext context) { diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index f220dfa..d7c3d30 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -1,18 +1,16 @@ using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.HostedServices; +using Api.SeaHavenIndustries.Infrastructure; using Api.SeaHavenIndustries.Middleware; using Api.SeaHavenIndustries.Observability; using Api.SeaHavenIndustries.Options; using Data.SeaHavenIndustries; -using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using Sentry.AspNetCore; using Sentry.Extensibility; -using System.Text; using SeaHaven.DataServices.DependencyInjection; using SeaHaven.Services.DependencyInjection; using SeaHaven.Services.Implementation; @@ -43,12 +41,7 @@ ConfigurationManager configuration = builder.Configuration; builder.Services.AddDbContext(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); -builder.Services.AddIdentity(options => -{ - options.User.RequireUniqueEmail = false; -}) - .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); +builder.Services.AddSeaHavenIdentity(); builder.Services.AddControllers(options => { @@ -63,6 +56,8 @@ builder.Services.AddResponseCompression(opts => opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( new[] { "application/octet-stream" }); }); +builder.Services.AddPasswordResetRateLimiting(); +builder.Services.AddPasswordResetEmailDelivery(); builder.Services.AddCors(option => option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); @@ -147,27 +142,7 @@ builder.Services.AddOptions -{ - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; -}) -.AddJwtBearer(options => -{ - options.SaveToken = true; - options.RequireHttpsMetadata = false; - options.TokenValidationParameters = new TokenValidationParameters() - { - ValidateIssuer = true, - ValidateAudience = true, - ValidAudience = configuration["JWT:ValidAudience"], - ValidIssuer = configuration["JWT:ValidIssuer"], - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes( - configuration["JWT:Secret"] - ?? throw new InvalidOperationException("JWT:Secret configuration is required"))) - }; -}); +builder.Services.AddSeaHavenJwtAuthentication(configuration); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { @@ -199,6 +174,9 @@ builder.Services.AddSwaggerGen(c => var app = builder.Build(); +// First, so every later middleware and the rate limiter see the real client address. +app.UseForwardedHeaders(); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment() || app.Environment.IsProduction()) { @@ -216,12 +194,20 @@ app.UseStaticFiles(); app.UseCors(); app.UseMiddleware(); app.UseRouting(); +app.UseRateLimiter(); app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); app.MapControllers(); +using (var ownerScope = app.Services.CreateScope()) +{ + await ownerScope.ServiceProvider + .GetRequiredService() + .EnsureConfiguredOwnerAsync(CancellationToken.None); +} + //if (app.Environment.IsDevelopment()) //{ // using var scope = app.Services.CreateScope(); diff --git a/BACKEND_ARCHITECTURE.md b/BACKEND_ARCHITECTURE.md deleted file mode 100644 index ca27a73..0000000 --- a/BACKEND_ARCHITECTURE.md +++ /dev/null @@ -1,106 +0,0 @@ -# Backend Architecture and Quality Rules - -This repository uses a feature-oriented service boundary over Entity Framework Core: - -```text -HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext -``` - -The interfaces are architectural seams, not a requirement to wrap every class or every -EF method. Add an interface when it separates HTTP, business, persistence, or external -I/O responsibilities and enables behavior-focused testing. - -## Layer responsibilities - -### API controllers - -- Parse HTTP input, authorize the caller, invoke a business-service interface, and map - the result to the established HTTP contract. -- Do not inject `ApplicationDbContext`, concrete service implementations, or concrete - data-service implementations. -- Do not contain persistence queries or business workflows. -- Do not expose exception messages to clients. Map known failures explicitly and let the - centralized exception boundary handle unexpected failures. - -### Business services - -- Own validation, business decisions, orchestration, and transaction intent. -- Depend on interfaces for persistence and external I/O. -- Use `IOptions` for typed configuration. Do not inject `IConfiguration`. -- Keep feature responsibilities cohesive. Split a service when it has independently - changing business reasons, not because it crossed an arbitrary line count. -- Preserve existing API and board-backed behavior unless the accepted requirement - explicitly changes it. - -### Data services - -- Own EF Core query shape, persistence, batching, and transaction implementation. -- Accept cancellation tokens on new I/O methods and pass them to EF Core. -- Use `AsNoTracking()` for read-only queries. -- Project only required columns for list/read models; include full graphs only when the - caller needs them. -- Page unbounded collections at the database. -- Avoid query-in-loop and save-in-loop patterns. Prefer set-based reads and batched writes - while preserving the workflow's failure and transaction semantics. -- Do not introduce a generic repository over EF Core. Feature data services should expose - intent-revealing operations. - -## Performance review - -For each changed hot path, document and test: - -- input size variables; -- CPU and memory complexity; -- database round trips; -- whether filtering, ordering, and paging execute in SQL; -- whether tracking is necessary; -- whether repeated work can be hoisted out of loops; -- the partial-failure and transaction behavior. - -Prefer dictionary or hash-set reconstruction when database results must follow caller -order. A query followed by dictionary reconstruction is normally `O(n)` CPU and memory; -repeated `First`/`Single` scans over the result are `O(n²)`. - -Do not optimize by weakening correctness. In particular, batching all writes into one -final save can change partial-success behavior, and adding a transaction can change -locking and retry semantics. Those are business decisions, not mechanical cleanup. - -## Configuration - -Configuration is bound once during composition in -`SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services receive -typed options such as `FrontendOptions`, `JwtOptions`, `ApprovalsOptions`, and -`VendorPortalOptions`. - -Defaults must preserve current behavior. Startup validation should be introduced only -when every deployed environment is known to provide the required value. - -## Enforcement - -`ArchitectureTests` enforces mechanical dependency rules: - -- controllers cannot inject EF contexts or concrete service/data-service classes; -- business services cannot inject EF contexts or raw `IConfiguration`. - -The pull-request quality workflow runs those tests and verifies formatting for changed -C# files. The normal CI workflow builds the solution and runs the full test suite. - -Architecture tests are appropriate for dependency direction. Behavior belongs in tests -through public interfaces. Do not add tests merely to prove a linter or analyzer itself -works; test the product behavior the rule protects. - -## Review checklist - -- Controller depends only on service abstractions. -- Business logic is in a cohesive feature service. -- EF operations are behind a feature data-service abstraction. -- No raw `IConfiguration` in a business service. -- No unbounded load followed by in-memory filtering. -- No query/save loop where a set-based operation preserves semantics. -- Read-only EF queries use no tracking. -- New async I/O accepts and propagates cancellation where the public contract permits. -- Errors do not leak internal exception details. -- Tests cover behavior, authorization, data contracts, ordering, duplicates, missing - records, and relevant failure boundaries. -- Changed behavior has been checked against the authoritative ticket board. Missing board - access blocks readiness and merge claims. diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index a5256dc..f60dd32 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -54,6 +54,23 @@ namespace Data.SeaHavenIndustries builder.Entity() .HasIndex(t => new { t.ServiceKey, t.IsActive }); + builder.Entity() + .Property(t => t.ExtraSafetyNote) + .HasMaxLength(CompletionDocTemplate.ExtraSafetyNoteMaxLength); + + builder.Entity(entity => + { + entity.Property(p => p.Name) + .HasMaxLength(CompletionDocTemplateProcedure.NameMaxLength); + + entity.HasOne(p => p.CompletionDocTemplate) + .WithMany(t => t.Procedures) + .HasForeignKey(p => p.CompletionDocTemplateId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(p => new { p.CompletionDocTemplateId, p.SortOrder }); + }); + builder.Entity() .Property(w => w.RowVersion) @@ -80,6 +97,12 @@ namespace Data.SeaHavenIndustries .HasIndex(w => w.InternalWONumber) .HasFilter("[istemplate] = 0"); + builder.Entity() + .HasOne(w => w.ServiceDefinition) + .WithMany() + .HasForeignKey(w => w.ServiceId) + .OnDelete(DeleteBehavior.Restrict); + builder.Entity() .HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate }) .HasFilter("[istemplate] = 0"); @@ -170,6 +193,12 @@ namespace Data.SeaHavenIndustries builder.Entity() .HasIndex(u => u.AccountId); + builder.Entity() + .HasIndex(u => u.IsAccountOwner) + .IsUnique() + .HasFilter("IsAccountOwner = 1") + .HasDatabaseName("IX_AspNetUsers_IsAccountOwner"); + builder.Entity() .HasIndex(c => c.NormalizedName) .IsUnique(); @@ -206,6 +235,133 @@ namespace Data.SeaHavenIndustries builder.Entity() .HasIndex(t => t.NormalizedName) .IsUnique(); + + // Bounded lengths keep the unique NormalizedName key SQL + // Server-indexable (nvarchar(max) is not). + builder.Entity() + .Property(s => s.Name) + .HasMaxLength(200); + + builder.Entity() + .Property(s => s.NormalizedName) + .HasMaxLength(200); + + builder.Entity() + .HasIndex(s => s.NormalizedName) + .IsUnique(); + + builder.Entity() + .Property(s => s.Trade) + .HasMaxLength(64); + + builder.Entity() + .HasIndex(s => s.Trade); + + builder.Entity() + .Property(s => s.IconKey) + .HasMaxLength(64); + + builder.Entity() + .HasIndex(s => s.IsActive); + + builder.Entity() + .HasOne(s => s.CompletionDocTemplate) + .WithMany() + .HasForeignKey(s => s.CompletionDocTemplateId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(s => s.CompletionDocTemplateId); + + builder.Entity() + .HasIndex(t => new { t.ServiceId, t.WorkOrderType }) + .IsUnique(); + + builder.Entity() + .HasOne(t => t.Service) + .WithMany(s => s.SupportedWorkOrderTypes) + .HasForeignKey(t => t.ServiceId) + .OnDelete(DeleteBehavior.Restrict); + + // SH-278 Area catalogue: bounded names so the unique key is indexable. + builder.Entity() + .Property(a => a.Name) + .HasMaxLength(128); + + builder.Entity() + .Property(a => a.NormalizedName) + .HasMaxLength(128); + + builder.Entity() + .HasIndex(a => a.NormalizedName) + .IsUnique(); + + builder.Entity() + .HasOne(c => c.Area) + .WithMany() + .HasForeignKey(c => c.AreaId) + .OnDelete(DeleteBehavior.Restrict); + + // Per-person team permission overrides. Composite primary key + // (UserId + PermissionKey) plus an explicitly named unique composite + // index; missing rows behave as Unset. + builder.Entity(entity => + { + entity.HasKey(o => new { o.UserId, o.PermissionKey }); + + entity.Property(o => o.UserId) + .HasMaxLength(450); + + entity.Property(o => o.PermissionKey) + .HasMaxLength(64); + + entity.Property(o => o.State) + .IsRequired(); + + entity.HasOne(o => o.User) + .WithMany() + .HasForeignKey(o => o.UserId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasIndex(o => new { o.UserId, o.PermissionKey }) + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + }); + + builder.Entity(entity => + { + entity.HasIndex(invite => invite.TokenHash) + .IsUnique() + .HasDatabaseName("IX_TeamMemberInvites_TokenHash"); + + entity.HasIndex(invite => invite.UserId) + .HasDatabaseName("IX_TeamMemberInvites_UserId"); + + entity.HasOne(invite => invite.User) + .WithMany() + .HasForeignKey(invite => invite.UserId) + .OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.HasKey(area => new { area.UserId, area.Area }); + + entity.Property(area => area.UserId) + .HasMaxLength(450); + + entity.Property(area => area.Area) + .HasMaxLength(32); + + entity.HasOne(area => area.User) + .WithMany(user => user.ServiceAreas) + .HasForeignKey(area => area.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(area => new { area.UserId, area.Area }) + .IsUnique() + .HasDatabaseName("IX_UserServiceAreas_UserId_Area"); + }); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -220,6 +376,7 @@ namespace Data.SeaHavenIndustries public DbSet workOrderCategories { get; set; } public DbSet workOrderAttachments { get; set; } public DbSet CompletionDocTemplates { get; set; } + public DbSet CompletionDocTemplateProcedures { get; set; } public DbSet WorkOrderAuditLogs { get; set; } public DbSet WorkOrderFieldLocks { get; set; } public DbSet WorkOrderWeekRolledLedgers { get; set; } @@ -228,6 +385,7 @@ namespace Data.SeaHavenIndustries public DbSet WorkOrderExternalReceipts { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Trades { get; set; } + public DbSet Areas { get; set; } public DbSet Vendors { get; set; } public DbSet VendorCompanies { get; set; } public DbSet VendorAuditLogs { get; set; } @@ -241,6 +399,8 @@ namespace Data.SeaHavenIndustries public DbSet DispatchUpliftRequests { get; set; } public DbSet TaskListTemplates { get; set; } public DbSet TaskListTemplateItems { get; set; } + public DbSet Services { get; set; } + public DbSet ServiceWorkOrderTypes { get; set; } // New DbSets for added entities public DbSet Accounts { get; set; } @@ -259,6 +419,9 @@ namespace Data.SeaHavenIndustries public DbSet Departments { get; set; } public DbSet JobTitles { get; set; } public DbSet Regions { get; set; } + public DbSet UserPermissionOverrides { get; set; } + public DbSet UserServiceAreas { get; set; } + public DbSet TeamMemberInvites { get; set; } public override int SaveChanges() { @@ -338,12 +501,16 @@ namespace Data.SeaHavenIndustries public string? Initials { get; set; } public string? Color { get; set; } public int? Type { get; set; } // 1 for users 0 for admin + public bool IsAccountOwner { get; set; } + public bool? PendingRegistration { get; set; } + public DateTime? PendingRegistrationCreatedDate { get; set; } /// Optional CRM account membership for server-derived media scope (SH-221). public int? AccountId { get; set; } [ForeignKey(nameof(AccountId))] public virtual Accounts? Account { get; set; } public ICollection