merge(main): keep uplift evidence on the current work-order routes

The route contract keeps the poc endpoint from main and the uplift evidence routes from this branch. Create still passes the scanned evidence document through the locked mutation.
This commit is contained in:
Arthur Bassi 2026-09-28 15:56:45 -03:00
commit 31dd2d5dcd
415 changed files with 107331 additions and 3497 deletions

View file

@ -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)
#

63
.gitattributes vendored
View file

@ -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

22
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,22 @@
<!--
Title: type(scope): description (SH-123)
type is one of feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, release.
Product work carries its SH key at the end of the title. Platform or security work carries PLAT or SEC.
Docs-only and configuration-only chores may omit the key.
Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ plus a kebab-case description. No Jira keys in branch names.
Scope: one logical change per PR. If the title needs "and", split it.
Body: verifiable facts about the change. No validation transcripts, no deployment notes, no AI attribution footers.
The layout below is this repository's contract (REVIEW_AND_PR_FRAMEWORK.md, section 8). Keep the three headings.
-->
## Summary
<!-- What changed and why, in plain language. Two to four sentences. -->
## Changes and value
<!-- Grouped by area, each with the value it delivers. Bullets, bold lead-in per bullet. -->
## Ticket
<!-- The board key(s) this PR delivers, one per line. "None" when nothing applies. -->

34
.github/renovate.json vendored
View file

@ -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=\"(?<currentValue>\\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\\./"],

View file

@ -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

View file

@ -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

View file

@ -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

45
.github/workflows/deploy-tag.yaml vendored Normal file
View file

@ -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

405
.github/workflows/deploy.yaml vendored Normal file
View file

@ -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

View file

@ -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

91
.github/workflows/release.yaml vendored Normal file
View file

@ -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

View file

@ -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;

View file

@ -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)

View file

@ -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).

View file

@ -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<ApplicationDbContext>()
.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<InvalidOperationException>()
.WithMessage("The configured account owner user was not found.");
}
}

View file

@ -117,11 +117,11 @@ public class AuthenticationControllerTests
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny<CancellationToken>()))
.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<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<Response>().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<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().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<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "weak", It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().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<IAuthenticationService>();
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<BadRequestObjectResult>().Subject;
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Passwords don't match");
service.Verify(
s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ChangePassword_NonPolicyFailure_ReturnsTheGenericMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "Next2@x", It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>().Subject.Value.Should().BeOfType<Response>().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<IAuthenticationService>();
service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>())).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<OkObjectResult>().Subject;
var ok = registered.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<Response>().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<OkObjectResult>().Subject.Value).Should().Be(Json(ok.Value));
service.Verify(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>()), Times.Once);
service.Verify(s => s.ForgetPasswordAsync("x@y.com", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ForgetPassword_NotFound_ReturnsNoSuchEmailMessage()
public async Task ForgetPassword_WhenServiceThrows_StillAnswersTheSameSuccessAndLogsNoDetail()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SECRET a@b.com"));
var logger = new Mock<ILogger<AuthenticationController>>();
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).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<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Error");
response.Message.Should().Be("No such email is registered");
var response = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((state, _) => !state.ToString()!.Contains("a@b.com") && !state.ToString()!.Contains("SECRET")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
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<IAuthenticationService>();
service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny<CancellationToken>())).ReturnsAsync(matched);
service.Setup(s => s.VerifyCodeAsync("a@b.com", "123456", It.IsAny<CancellationToken>())).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<IAuthenticationService>();
var controller = NewController(service);
var result = await controller.VerificationCode(null, CancellationToken.None);
result.Should().BeOfType<BadRequestObjectResult>().Subject.Value.Should().BeOfType<Response>()
.Which.Message.Should().Be("Code Not Matched");
service.Verify(s => s.VerifyCodeAsync(null, null, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task VerificationCode_WhenServiceThrows_AnswersTheGenericWrongCodeError()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.VerifyCodeAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>().Subject.Value)
.Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" }));
}
[Fact]
public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage()
{

View file

@ -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<IUserDataService> userData,
Mock<IForgetPasswordDataService> forget,
Mock<IEmailSender> email,
Mock<IPasswordResetEmailQueue> email,
out Mock<IUserRoleStore<ApplicationUser>> store,
out Mock<IPasswordHasher<ApplicationUser>> 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<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out _);
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out _);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).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<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out var hasher);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(deletedUser);
store.Setup(s => s.GetRolesAsync(deletedUser, It.IsAny<CancellationToken>())).ReturnsAsync(new List<string>());
@ -64,7 +67,7 @@ public class AuthenticationServiceTests
public async Task Login_ValidUser_ReturnsTokenFirstRoleAndIdentity()
{
var user = IdentityTestHelpers.User();
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out var hasher);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
store.Setup(s => s.GetRolesAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync(new List<string> { "Admin", "Manager" });
store.As<Microsoft.AspNetCore.Identity.IUserPasswordStore<ApplicationUser>>()
@ -87,7 +90,7 @@ public class AuthenticationServiceTests
public async Task Login_BadPassword_ReturnsNull()
{
var user = IdentityTestHelpers.User();
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out var hasher);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
store.As<Microsoft.AspNetCore.Identity.IUserPasswordStore<ApplicationUser>>()
.Setup(s => s.GetPasswordHashAsync(user, It.IsAny<CancellationToken>())).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<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IEmailSender>();
var email = new Mock<IPasswordResetEmailQueue>();
string? stored = null, salt = null, body = null;
DateTime expires = default;
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, DateTime, DateTime, CancellationToken>((_, _, h, s, e, _, _) => { stored = h; salt = s; expires = e; })
.Returns(Task.CompletedTask);
email.Setup(e => e.TryEnqueue(user.Email!, "Forget Password Request.", It.IsAny<string>()))
.Callback<string, string, string>((_, _, 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<string>(code => code.Length == 6 && code.All(char.IsDigit)),
It.IsAny<CancellationToken>()),
Times.Once);
email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is<string>(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<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(IdentityTestHelpers.User());
var registered = new Mock<IForgetPasswordDataService>();
var unregistered = new Mock<IForgetPasswordDataService>();
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).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<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Once);
email.Verify(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task ForgetPassword_EmailThatCannotBeQueued_DoesNotCountTheRequest()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IEmailSender>();
var stored = new List<(string Hash, string Salt)>();
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, DateTime, DateTime, CancellationToken>((_, _, h, s, _, _, _) => stored.Add((h, s)))
.Returns(Task.CompletedTask);
var email = new Mock<IPasswordResetEmailQueue>();
var bodies = new List<string>();
// The queue is full for the first three requests.
email.Setup(e => e.TryEnqueue(user.Email!, It.IsAny<string>(), It.IsAny<string>()))
.Returns<string, string, string>((_, _, 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<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), 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<string>(), It.IsAny<string>()), 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<string>(), It.IsAny<CancellationToken>()), 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<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny<CancellationToken>())).ReturnsAsync(exists);
forget.Setup(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")));
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(true);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), 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<Exception>();
email.Verify(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ResetPassword_NoMatchingCode_ReturnsFalseWithoutReset()
public async Task ForgetPassword_EmailThatCannotBeQueued_MakesTheSameDataCallsAsAnUnknownEmail()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(IdentityTestHelpers.User());
var registered = new Mock<IForgetPasswordDataService>();
var unregistered = new Mock<IForgetPasswordDataService>();
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).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<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(IdentityTestHelpers.User(isDeleted: true));
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IPasswordResetEmailQueue>();
var service = NewService(userData, forget, email, out _, out _);
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
email.Verify(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), 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<IForgetPasswordDataService>(MockBehavior.Strict);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), 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<IForgetPasswordDataService>();
forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
forget.Setup(f => f.GetByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ForgetPasswordCode?)null);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out var store, out _);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), 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<string>(), It.IsAny<CancellationToken>()), Times.Never);
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
forget.Verify(f => f.TryConsumeAttemptAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), 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<IForgetPasswordDataService>();
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(pending);
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), 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<CancellationToken>()), Times.Once);
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<IForgetPasswordDataService>();
var lookups = 0;
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>()))
.Returns(() => ++lookups <= InMemoryPasswordResetThrottle.FailedChecksPerDay
? Task.FromException<ForgetPasswordCode?>(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
: Task.FromResult<ForgetPasswordCode?>(pending));
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>())).ReturnsAsync(true);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++)
{
var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
(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<IForgetPasswordDataService>();
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(pending);
var consumes = 0;
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(() => ++consumes <= InMemoryPasswordResetThrottle.FailedChecksPerDay
? Task.FromException<bool>(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
: Task.FromResult(true));
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++)
{
var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
(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<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var writes = 0;
forget.Setup(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(() => ++writes <= InMemoryPasswordResetThrottle.CodeRequestsPerHour
? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
: Task.CompletedTask);
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).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<Exception>();
}
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
writes.Should().Be(InMemoryPasswordResetThrottle.CodeRequestsPerHour + 1);
forget.Verify(f => f.PurgeExpiredAsync(It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), 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();
}
}

View file

@ -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]

View file

@ -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<IDashboardService> 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<IDashboardService>();
service.Setup(s => s.GetStatsAsync(
It.IsAny<ClaimsPrincipal>(),
It.IsAny<DashboardStatsQueryDTO>(),
It.IsAny<CancellationToken>()))
.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<OkObjectResult>().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);
}
}

File diff suppressed because it is too large Load diff

View file

@ -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;
/// <summary>
/// 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).
/// </summary>
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<LifecycleStatus> OpenStatuses = Enum.GetValues<LifecycleStatus>()
.Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled)
.ToList();
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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<IEnumerable<int>> Ids(LifecycleStatus status)
=> (await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = From,
DateTo = To,
Statuses = new List<LifecycleStatus> { 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<string> { "__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 });
}
}

View file

@ -31,7 +31,9 @@ public class LocationControllerSitesTests
dataService,
new AccountDataService(ctx),
Mock.Of<ICreateLocationValidation>(),
Mock.Of<IUpdateLocationValidation>());
Mock.Of<IUpdateLocationValidation>(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
var controller = new LocationController(service, Mock.Of<ILogger<LocationController>>())
{

View file

@ -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<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", It.IsAny<CancellationToken>()))
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(new PagedResult<LocationDTO>
{
Items = new List<LocationDTO> { 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<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
@ -44,6 +45,43 @@ public class LocationControllerTests
envelope.TotalPages.Should().Be(1);
}
[Fact]
public async Task GetLocationList_PassesStatesFilterToService()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(new PagedResult<LocationDTO>
{
Items = new List<LocationDTO>(),
TotalCount = 0,
Page = 1,
PageSize = 10
});
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", cancellationToken: CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
service.VerifyAll();
}
[Fact]
public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.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<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().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<NotFoundObjectResult>().Subject;
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Location not found");
}
[Fact]
public async Task GetLocationList_ForwardsSortParamsToService()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"))
.ReturnsAsync(new PagedResult<LocationDTO> { Items = new List<LocationDTO>(), 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<CancellationToken>(), "city", "desc"), Times.Once);
}
[Fact]
public async Task GetLocationList_WhenSortInvalid_ReturnsExistingValidationErrorShape()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.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<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Validation Error");
response.Message.Should().Contain("sortBy");
}
[Fact]
public async Task GetLocationList_ProjectsClientAccountNameAndSiteFields()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), null, null))
.ReturnsAsync(new PagedResult<LocationDTO>
{
Items = new List<LocationDTO>
{
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<SiteContactResponseDTO>
{
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<OkObjectResult>().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<ILocationService>();
service.Setup(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException());
var result = await NewController(service).AddLocation(new Location_DTO { Name = "BK5" }, CancellationToken.None);
var conflict = result.Should().BeOfType<ConflictObjectResult>().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<ILocationService>();
service.Setup(s => s.UpdateLocationFromRequestAsync(4, It.IsAny<LocationUpdateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException());
var result = await NewController(service).EditLocation(4, new EditLocation_DTO { Name = "BK5" }, CancellationToken.None);
result.Should().BeOfType<ConflictObjectResult>();
}
[Fact]
public async Task DeleteLocation_WithoutPermission_Returns403WithDeleteMessage()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.DeleteLocationByIdAsync(4, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(403);
forbidden.Value.Should().BeOfType<Response>().Which.Message.Should().Be("You are not allowed to delete sites.");
}
[Fact]
public async Task GetOpenWorkOrders_ReturnsCountAndIds_Or404()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetOpenWorkOrdersAsync(4, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } });
var ok = (await NewController(service).GetOpenWorkOrders(4, CancellationToken.None))
.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } });
(await NewController(service).GetOpenWorkOrders(5, CancellationToken.None))
.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task UpdateSiteContactInfo_MapsContactsAndNotesToTheService()
{
var service = new Mock<ILocationService>();
SiteContactInfoRequestDTO? seen = null;
service.Setup(s => s.UpdateSiteContactInfoAsync(4, It.IsAny<SiteContactInfoRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.Callback<int, SiteContactInfoRequestDTO, ClaimsPrincipal, CancellationToken>((_, request, _, _) => seen = request)
.Returns(Task.CompletedTask);
var result = await NewController(service).UpdateSiteContactInfo(4, new SiteContactInfoInput_DTO
{
Contacts = new List<SiteContactInput_DTO> { new() { Id = 7, Name = "Main", Phone = "555-0100" } },
Notes = "Gate 4"
}, CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
seen!.Notes.Should().Be("Gate 4");
seen.Contacts.Should().ContainSingle().Which.Should().BeEquivalentTo(new SiteContactRequestDTO { Id = 7, Name = "Main", Phone = "555-0100" });
}
}

View file

@ -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<ApplicationDbContext>()
.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");
}
}

View file

@ -15,6 +15,27 @@ namespace Api.SeaHavenIndustries.Tests;
public class LocationServiceTests
{
/// <summary>Fills the fields a new site must carry (client, address, one contact) unless the test set them.</summary>
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<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } };
return request;
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
@ -28,7 +49,9 @@ public class LocationServiceTests
new LocationDataService(ctx),
new AccountDataService(ctx),
new CreateLocationValidation(),
new UpdateLocationValidation());
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
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<ILocationDataService>();
IReadOnlyCollection<string>? captured = default;
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states)
.ReturnsAsync((new List<Locations>(), 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<ILocationDataService>();
IReadOnlyCollection<string>? captured = default;
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter)
.ReturnsAsync((new List<Locations>(), 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<ILocationDataService>();
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, "TX, zz, QQ", CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.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<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes()
{
var data = new Mock<ILocationDataService>();
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync((new List<Locations>(), 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<IAccountDataService>(),
new CreateLocationValidation(),
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
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<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
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<ILocationDataService>();
string? capturedSort = "sentinel";
string? capturedDirection = "sentinel";
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) =>
{
capturedSort = sortBy;
capturedDirection = sortDirection;
})
.ReturnsAsync((new List<Locations>(), 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<ILocationDataService>();
string? capturedSort = "sentinel";
string? capturedDirection = "sentinel";
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) =>
{
capturedSort = s;
capturedDirection = d;
})
.ReturnsAsync((new List<Locations>(), 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<ILocationDataService>();
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e =>
e.PropertyName == "sortBy"
&& e.ErrorMessage.Contains("code")
&& e.ErrorMessage.Contains("poc"));
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Theory]
[InlineData("ascending")]
[InlineData("DESC; drop table locations")]
public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection)
{
var data = new Mock<ILocationDataService>();
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e =>
e.PropertyName == "sortDirection"
&& e.ErrorMessage.Contains("asc")
&& e.ErrorMessage.Contains("desc"));
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), 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");
}
}

View file

@ -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
{
/// <summary>Fills the fields a new site must carry (client, address, one contact) unless the test set them.</summary>
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<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } };
return request;
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
private static ClaimsPrincipal OrgWideAdmin()
{
var claims = new List<Claim>
{
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<Locations> SeedLocationWithContactsAsync(ApplicationDbContext ctx)
{
var service = NewService(ctx);
await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Depot",
Phone = "555-9000",
AccountId = null,
Contacts = new List<SiteContactRequestDTO>
{
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<SiteContactRequestDTO>
{
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<SiteContactRequestDTO>()
}), OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.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<SiteContactRequestDTO> { 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<FluentValidation.ValidationException>())
.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<SiteContactRequestDTO>
{
new() { Name = new string('x', 101), Phone = new string('5', 21) }
}
}), OrgWideAdmin(), CancellationToken.None);
var thrown = (await act.Should().ThrowAsync<FluentValidation.ValidationException>()).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<FluentValidation.ValidationException>())
.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<SiteContactRequestDTO>()
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.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<SiteContactRequestDTO>
{
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<SiteContactRequestDTO> { 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<SiteContactRequestDTO>
{
new() { Name = "Alice Cooper", Phone = "555-0100" },
new() { Id = 901, Name = "Foreign", Phone = "555-0900" }
}
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.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<SiteContactRequestDTO> { new() { Id = bob.Id, Name = "Bob Dillon", Phone = "555-0200" } }
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.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<SiteContactRequestDTO> { new() { Id = 902, Name = "AccountOnly", Phone = "555-0900" } }
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.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<SiteContactRequestDTO>
{
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<FluentValidation.ValidationException>())
.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<Claim>
{
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<SiteContactRequestDTO> { new() { Name = "Nope", Phone = "555-0000" } }
}, caller, CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
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<SiteContactRequestDTO> { 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<ILocationDataService>();
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync((new List<Locations>
{
new() { Id = 1, Name = "One" },
new() { Id = 2, Name = "Two" }
}, 2));
data.Setup(d => d.GetSiteContactsByLocationIdsAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Contacts>
{
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<IAccountDataService>(),
new CreateLocationValidation(),
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
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<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()), Times.Once);
data.Verify(d => d.GetSiteContactsByLocationIdsAsync(It.Is<IReadOnlyCollection<int>>(ids => ids.Contains(1) && ids.Contains(2)), It.IsAny<CancellationToken>()), 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<int>(), 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<AddColumnOperation>().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<DropColumnOperation>();
}
[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<ILogger<LocationController>>())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.GetLocationById(seeded.Id, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().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<ILogger<LocationController>>())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.GetLocationList(cancellationToken: CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().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);
}
}

View file

@ -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;
/// <summary>
/// Items addressed to the signed-in user: new assignments (SH-288), comments (SH-289),
/// mentions, and decisions on their uplift requests (SH-215).
/// </summary>
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<ApplicationDbContext>()
.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<NotificationItemDto> Items(NotificationFeedDto feed, string reason)
=> feed.Sections.SingleOrDefault(section => section.Reason == reason)?.Items
?? Array.Empty<NotificationItemDto>();
// ---- 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<OperationCanceledException>();
await FluentActions.Awaiting(() => data.GetUnansweredCommentsAsync(scope, Now, 10, cancelled.Token))
.Should().ThrowAsync<OperationCanceledException>();
await FluentActions.Awaiting(() => data.GetUpliftDecisionsAsync(scope, Now, 10, cancelled.Token))
.Should().ThrowAsync<OperationCanceledException>();
}
}

View file

@ -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<ApplicationDbContext>()
.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<NoVendorNotification>().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<NotificationItemDto>)[]
{
(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<NotificationItemDto>()),
(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<WorkOrder>();
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<WorkOrderBoardValidationException>()).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<WorkOrderBoardValidationException>()).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<OperationCanceledException>();
}
[Fact]
public async Task Controller_MapsForbiddenScopeTo403()
{
using var context = NewContext();
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = User(1, "Vendor") }
}
};
var result = await controller.GetFeed(CancellationToken.None);
result.Should().BeOfType<ObjectResult>().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<ISlaBreachAcknowledgementService>())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = User(1, "Admin") }
}
};
var result = await controller.GetFeed(CancellationToken.None);
var feed = result.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeOfType<NotificationFeedDto>().Subject;
feed.Sections.Single().Reason.Should().Be(NotificationReasons.Unassigned);
}
[Fact]
public void Controller_RequiresAnAuthenticatedCaller()
{
typeof(NotificationsController).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Should().NotBeEmpty();
}
}

View file

@ -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;
/// <summary>
/// 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).
/// </summary>
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<ApplicationDbContext>()
.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<string> ItemIds(NotificationFeedDto feed)
=> SlaSection(feed)?.Items.Select(item => item.Id) ?? Enumerable.Empty<string>();
[Fact]
public void ResponseWindows_AreTheRespondDeadlinesOnTheSeverityBadge()
{
SlaResponseWindows.Respond.ToDictionary(pair => pair.Key, pair => pair.Value.TotalMinutes)
.Should().Equal(new Dictionary<int, double>
{
[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<WorkOrderBoardValidationException>()).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<IStatusCodeActionResult>()
.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<OperationCanceledException>();
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
}
}

View file

@ -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;
/// <summary>
/// Exercises the password rule through the same Identity registration the API
/// host uses, so these assertions describe the rule that runs in production.
/// </summary>
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<ApplicationDbContext>(options =>
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
services.AddSeaHavenIdentity();
_provider = services.BuildServiceProvider();
_scope = _provider.CreateAsyncScope();
}
private UserManager<ApplicationUser> UserManager =>
_scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
[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<IOptions<IdentityOptions>>().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<UserManager<ApplicationUser>>(
Mock.Of<IUserStore<ApplicationUser>>(), 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<IUserDataService>(),
Mock.Of<IForgetPasswordDataService>(),
Mock.Of<IPasswordResetEmailQueue>(),
new InMemoryPasswordResetThrottle(TimeProvider.System),
TimeProvider.System,
Mock.Of<ISessionStampService>());
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<OperationCanceledException>();
}
[Fact]
public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode()
{
var user = await CreateUserAsync();
var forget = new Mock<IForgetPasswordDataService>();
var salt = PasswordResetCodeSecrets.NewSalt();
forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny<CancellationToken>()))
.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<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.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<string>(), It.IsAny<CancellationToken>()), Times.Never);
forget.Verify(f => f.RefundAttemptAsync(7, It.IsAny<CancellationToken>()), Times.Once);
}
private async Task<IReadOnlyList<IdentityError>> ValidateAsync(ApplicationUser user, string password)
{
var errors = new List<IdentityError>();
foreach (var validator in UserManager.PasswordValidators)
{
var result = await validator.ValidateAsync(UserManager, user, password);
errors.AddRange(result.Errors);
}
return errors;
}
private async Task<ApplicationUser> 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<IForgetPasswordDataService>? forget = null) =>
new(
UserManager,
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
Mock.Of<IPasswordResetEmailQueue>(),
new InMemoryPasswordResetThrottle(TimeProvider.System),
TimeProvider.System,
Mock.Of<ISessionStampService>());
public async ValueTask DisposeAsync()
{
await _scope.DisposeAsync();
await _provider.DisposeAsync();
}
}

View file

@ -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<PasswordResetEmailChannel>.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<PasswordResetEmailChannel>.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<string>();
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");
}
}

View file

@ -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<ITransactionTracer>();
var hub = new Mock<IHub>();
hub.Setup(h => h.PushScope()).Returns(Mock.Of<IDisposable>());
hub.Setup(h => h.StartTransaction(It.IsAny<ITransactionContext>(), It.IsAny<IReadOnlyDictionary<string, object?>>()))
.Returns(transaction.Object);
var sender = new Mock<IEmailSender>();
sender.Setup(s => s.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(accepted);
var services = new ServiceCollection().AddSingleton(sender.Object).BuildServiceProvider();
var channel = new PasswordResetEmailChannel(NullLogger<PasswordResetEmailChannel>.Instance);
var worker = new PasswordResetEmailSenderHostedService(
channel,
services.GetRequiredService<IServiceScopeFactory>(),
NullLogger<PasswordResetEmailSenderHostedService>.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<Exception>(), It.IsAny<SpanStatus>()), Times.Never);
}
else
{
transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Never);
transaction.Verify(t => t.Finish(
It.Is<Exception>(ex => !ex.Message.Contains("user@example.com") && !ex.Message.Contains("123456")),
SpanStatus.InternalError), Times.Once);
}
}
}

View file

@ -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, "<p>Hi</p>"));
Assert.Equal(delivered, await sender.SendEmailAsync(Recipient, Subject, "<p>Hi</p>"));
Assert.Equal(delivered, await sender.SendEMailAttachment(Recipient, Subject, new byte[] { 1, 2, 3 }));
Assert.Equal(delivered, await sender.SendDispatchEmail(Recipient, Subject, "<p>Hi</p>", 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, "<p>secret link</p>"));
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, "<p>Hi</p>"));
var entry = Assert.Single(logger.Entries);
Assert.Contains(nameof(HttpRequestException), entry);
Assert.DoesNotContain(Recipient, entry);
}
private sealed class StubbedSendMessage : SendMessage
{
private readonly Mock<ISendGridClient> _client = new();
public StubbedSendMessage(HttpStatusCode status, ILogger<SendMessage> logger)
: base(new ConfigurationBuilder().Build(), logger)
{
_client
.Setup(client => client.SendEmailAsync(It.IsAny<SendGridMessage>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new Response(status, new StringContent(RejectionBody), null));
}
public StubbedSendMessage(Exception failure, ILogger<SendMessage> logger)
: base(new ConfigurationBuilder().Build(), logger)
{
_client
.Setup(client => client.SendEmailAsync(It.IsAny<SendGridMessage>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(failure);
}
protected override ISendGridClient CreateClient(string? apiKey) => _client.Object;
}
private sealed class CapturingLogger : ILogger<SendMessage>
{
public List<string> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add($"{formatter(state, exception)} {exception}");
}
}

View file

@ -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]

View file

@ -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));

View file

@ -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<SeaHaven.Services.Interfaces.ISessionStampCache>();
cache.Should().BeOfType<InMemorySessionStampCache>();
second.ServiceProvider.GetRequiredService<SeaHaven.Services.Interfaces.ISessionStampCache>().Should().BeSameAs(cache);
}
}

View file

@ -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<IServicesRegistryService> 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<IServicesRegistryService>();
service.Setup(s => s.GetAllAsync(true, WorkOrderType.PM, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ServiceDto> { new() { Id = 1, Name = "Leak", IsActive = true } });
var result = await NewController(service, "Admin").GetAll(true, WorkOrderType.PM, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
(ok.Value as IEnumerable<ServiceDto>).Should().ContainSingle(s => s.Name == "Leak");
}
[Fact]
public async Task GetAll_WhenValidationCode_ReturnsBadRequestWithStableCodeAndMessage()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.GetAllAsync(null, WorkOrderType.Project, It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>().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<IServicesRegistryService>();
service.Setup(s => s.GetByIdAsync(9, It.IsAny<CancellationToken>()))
.ReturnsAsync((ServiceDto?)null);
var result = await NewController(service, "Admin").GetById(9, CancellationToken.None);
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
ErrorValue(notFound)!.Code.Should().Be("NotFound");
}
[Fact]
public async Task Create_PassesCallerAndReturnsCreated()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.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<ObjectResult>().Subject;
created.StatusCode.Should().Be(StatusCodes.Status201Created);
(created.Value as ServiceDto)!.Id.Should().Be(42);
service.Verify(s => s.CreateAsync(
It.Is<ClaimsPrincipal>(p => p.IsInRole("Scheduler")),
It.Is<ServiceInput>(i => i.Name == "Leak"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Create_WhenForbidden_Returns403WithStableCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.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<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
ErrorValue(forbidden)!.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Create_WhenDuplicateName_Returns400WithStableCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>().Subject;
ErrorValue(bad)!.Code.Should().Be("DuplicateName");
}
[Fact]
public async Task Update_ReturnsOkWithUpdatedService()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.UpdateAsync(It.IsAny<ClaimsPrincipal>(), 7, It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.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<OkObjectResult>().Subject;
(ok.Value as ServiceDto)!.Name.Should().Be("Renamed");
}
[Fact]
public async Task Update_WhenNotFound_Returns404WithCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.UpdateAsync(It.IsAny<ClaimsPrincipal>(), 404, It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("NotFound", "Service not found."));
var result = await NewController(service, "Admin").Update(404, new ServiceInput(), CancellationToken.None);
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
ErrorValue(notFound)!.Code.Should().Be("NotFound");
}
[Fact]
public async Task Deactivate_ReturnsOk()
{
var service = new Mock<IServicesRegistryService>();
var result = await NewController(service, "Admin").Deactivate(3, CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
service.Verify(s => s.DeactivateAsync(
It.Is<ClaimsPrincipal>(p => p.IsInRole("Admin")), 3, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Deactivate_WhenForbidden_Returns403WithStableCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.DeactivateAsync(It.IsAny<ClaimsPrincipal>(), 3, It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services."));
var result = await NewController(service, "Scheduler").Deactivate(3, CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
ErrorValue(forbidden)!.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Deactivate_WhenNotFound_Returns404WithCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.DeactivateAsync(It.IsAny<ClaimsPrincipal>(), 404, It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("NotFound", "Service not found."));
var result = await NewController(service, "Admin").Deactivate(404, CancellationToken.None);
result.Should().BeOfType<NotFoundObjectResult>();
}
}

View file

@ -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<ApplicationDbContext>()
.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<WorkOrderType>? types = null) => new()
{
Name = name,
Trade = trade,
IconKey = iconKey,
RequiresCompletionDocument = requiresDoc,
CompletionDocTemplateId = templateId,
SupportedWorkOrderTypes = types ?? new List<WorkOrderType>
{
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<ServicesRegistryValidationException>()).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<ServicesRegistryValidationException>()).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<ServicesRegistryValidationException>()).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<ServicesRegistryValidationException>()).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<ServicesRegistryValidationException>()).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<ServicesRegistryValidationException>()).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<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Create_WhenTemplateMissing_Inactive_OrDeleted_ThrowsTemplateInvalid()
{
using var ctx = NewContext();
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: 999), CancellationToken.None))
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
ctx.CompletionDocTemplates.Add(Template("Inactive", active: false));
ctx.CompletionDocTemplates.Add(Template("Deleted", deleted: true));
ctx.SaveChanges();
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Inactive").Id), CancellationToken.None))
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Deleted").Id), CancellationToken.None))
.Should().ThrowAsync<ServicesRegistryValidationException>()).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> { WorkOrderType.PM, unsupported });
var act = () => NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).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<ServicesRegistryValidationException>()).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<ServicesRegistryValidationException>()).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<ServicesRegistryValidationException>()).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<ServicesRegistryValidationException>()).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<ServicesRegistryValidationException>()).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<ServicesRegistryValidationException>()).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> { WorkOrderType.PM }), CancellationToken.None);
var reactive = await service.CreateAsync(Admin(), Input(name: "Reactive Service", trade: "HVAC", iconKey: "hvac", types: new List<WorkOrderType> { 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<ServicesRegistryValidationException>()).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();
}
}

View file

@ -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<IUserDataService> _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<CancellationToken>())).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<CancellationToken>()), 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<CancellationToken>()))
.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<string>(), It.IsAny<CancellationToken>()), 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);
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public class SiteRegistryServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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<ITeamPermissionOverrideDataService>();
permissions
.Setup(p => p.GetUserAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<Claim>
{
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<SiteContactRequestDTO> { 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<SiteCodeConflictException>();
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<FluentValidation.ValidationException>())
.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<FluentValidation.ValidationException>())
.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<SiteCodeConflictException>();
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<SiteForbiddenException>();
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<UnauthorizedAccessException>();
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<UnauthorizedAccessException>();
}
[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<SiteContactRequestDTO>
{
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<SiteContactRequestDTO> { new() { Name = "A", Phone = "1" } }
};
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(foreign.Id, request, AccountUser(1), CancellationToken.None)))
.Should().ThrowAsync<UnauthorizedAccessException>();
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(deleted.Id, request, OrgWide(), CancellationToken.None)))
.Should().ThrowAsync<KeyNotFoundException>();
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(
foreign.Id, new SiteContactInfoRequestDTO { Notes = "x" }, OrgWide(), CancellationToken.None)))
.Should().ThrowAsync<FluentValidation.ValidationException>();
}
[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<Contacts>() };
var data = new Mock<ILocationDataService>();
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<IAccountDataService>());
await service.GetOpenWorkOrdersAsync(5, OrgWide(), token);
await service.UpdateSiteContactInfoAsync(5, new SiteContactInfoRequestDTO
{
Contacts = new List<SiteContactRequestDTO> { 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<ILocationDataService>();
data.Setup(d => d.SiteCodeExistsAsync("BK5", 1, null, token)).ReturnsAsync(true);
var accounts = new Mock<IAccountDataService>();
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<SiteCodeConflictException>();
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<Contacts>() }
};
WorkOrderCompletionSnapshotMapper.Capture(workOrder);
using var frozen = JsonDocument.Parse(workOrder.FrozenPoc!);
frozen.RootElement.GetProperty("notes").GetString().Should().Be("Gate code 4411");
}
public static TheoryData<string, Action<LocationCreateRequestDTO>, 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<SiteContactRequestDTO>(), "At least one contact is required." },
{ "contact without phone", r => r.Contacts = new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = " " } }, "Contact phone is required." }
};
[Theory]
[MemberData(nameof(MissingSiteFields))]
public async Task Create_WithoutARequiredSiteField_IsAValidationErrorAndStoresNothing(
string _,
Action<LocationCreateRequestDTO> 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<FluentValidation.ValidationException>())
.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<IDispatchDataService>()).LocationExistsAsync(deleted.Id, CancellationToken.None)).Should().BeFalse();
}
}

View file

@ -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<AllowAnonymousAttribute>().Should().NotBeNull();
var cache = type.GetCustomAttribute<ResponseCacheAttribute>();
cache.Should().NotBeNull();
cache!.NoStore.Should().BeTrue();
}
[Fact]
public void RegistrationEndpoints_UseTheControllerScopedExceptionFilter()
{
var filter = typeof(TeamMemberInviteController).GetCustomAttribute<TypeFilterAttribute>();
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<InviteRegistrationExceptionFilter>.Instance)
.OnException(context);
context.ExceptionHandled.Should().BeTrue();
var result = context.Result.Should().BeOfType<ObjectResult>().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<InviteRegistrationExceptionFilter>.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<IFilterMetadata>()) { Exception = exception };
}
[Theory]
[InlineData(TeamMemberRegistrationStatus.InvalidInvite)]
[InlineData(TeamMemberRegistrationStatus.Ok)]
public async Task Complete_WithoutASession_ReturnsTheGenericInviteError(TeamMemberRegistrationStatus status)
{
var service = new Mock<ITeamMemberRegistrationService>();
service.Setup(x => x.CompleteAsync(It.IsAny<CompleteTeamMemberRegistrationRequestDTO>(), It.IsAny<CancellationToken>()))
.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<ObjectResult>().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<AuthorizeAttribute>().Should().NotBeNull();
typeof(TeamMemberController).GetMethod(nameof(TeamMemberController.ResendInvite))!
.GetCustomAttribute<AllowAnonymousAttribute>().Should().BeNull();
}
[Fact]
public async Task ResendInvite_Success_ReturnsInviteSent()
{
var invites = new Mock<ITeamMemberInviteService>();
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = true });
var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None);
result.Should().BeOfType<OkObjectResult>().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<ITeamMemberInviteService>();
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<ITeamMemberInviteService> invites)
{
return new TeamMemberController(Mock.Of<ITeamMemberService>(), Mock.Of<ITeamPermissionService>(), invites.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test"))
}
}
};
}
}

View file

@ -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<ITeamPermissionOverrideDataService>();
data.Setup(d => d.GetUserAsync("u1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamPermissionUserData
{
UserId = "u1",
RoleName = "Dispatcher",
Overrides = new Dictionary<string, UserPermissionState>
{
[TeamPermissionKeys.CreateCompletionDocTemplates] = UserPermissionState.Allow
}
});
var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }, "Bearer")));
var ok = Assert.IsType<OkObjectResult>(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<ITeamPermissionOverrideDataService>(MockBehavior.Strict);
var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity()));
var result = Assert.IsType<UnauthorizedResult>(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<IActionDescriptorCollectionProvider>()
.ActionDescriptors.Items
.OfType<ControllerActionDescriptor>()
.Single(d => d.ControllerTypeInfo == typeof(TeamMemberController)
&& d.ActionName == nameof(TeamMemberController.GetMyPermissions));
var methods = descriptor.ActionConstraints!.OfType<HttpMethodActionConstraint>()
.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<ITeamMemberService>(),
new TeamPermissionService(data, new TeamPermissionPolicy()),
Mock.Of<ITeamMemberInviteService>())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = user }
}
};
}

View file

@ -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<IUserServiceAreaDataService>(),
Mock.Of<ITeamPermissionOverrideDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<ITeamPermissionService>(),
Mock.Of<ITeamMemberInviteService>(),
Mock.Of<ISessionStampService>());
var result = await service.CreateAsync(
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
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<ApplicationUser>()), 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<string, UserPermissionState>
{
["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<ApplicationUser>()), 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<ApplicationUser>()))
.Callback<ApplicationUser>(user =>
{
user.Id = "new-user";
created = user;
})
.ReturnsAsync(IdentityResult.Success);
userManager
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((ApplicationUser?)null);
userManager
.Setup(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), "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<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "East", "West" })), It.IsAny<CancellationToken>()), Times.Once);
overrides.Verify(data => data.SetOverridesAsync("new-user", It.Is<IReadOnlyDictionary<string, UserPermissionState>>(value => value["deleteSites"] == UserPermissionState.Allow), It.IsAny<CancellationToken>()), 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<string> { "Admin" });
userData.Setup(data => data.IsAccountOwnerAsync(owner.Id, It.IsAny<CancellationToken>())).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<ApplicationUser>(), It.IsAny<CancellationToken>()), 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<string> { "Dispatcher" });
userManager.Setup(manager => manager.FindByEmailAsync("new@example.com")).ReturnsAsync((ApplicationUser?)null);
userManager.Setup(manager => manager.SetEmailAsync(user, "new@example.com"))
.Callback<ApplicationUser, string>((member, email) => member.Email = email)
.ReturnsAsync(IdentityResult.Success);
userManager.Setup(manager => manager.SetUserNameAsync(user, "new@example.com"))
.Callback<ApplicationUser, string>((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<CancellationToken>()))
.ReturnsAsync(new[] { "Central" });
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.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<string, UserPermissionState>
{
["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<CancellationToken>()), Times.Once);
userManager.Verify(manager => manager.UpdateSecurityStampAsync(user), Times.Once);
areas.Verify(data => data.ReplaceAsync(user.Id, It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "West" })), It.IsAny<CancellationToken>()), Times.Once);
overrides.Verify(data => data.ClearOverridesAsync(user.Id, It.IsAny<CancellationToken>()), Times.Once);
overrides.Verify(data => data.SetOverridesAsync(user.Id, It.Is<IReadOnlyDictionary<string, UserPermissionState>>(value => value["createSites"] == UserPermissionState.Deny), It.IsAny<CancellationToken>()), 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<string> { "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<CancellationToken>()))
.ReturnsAsync(Array.Empty<string>());
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.Success(new TeamPermissionProfileDTO
{
UserId = user.Id,
Permissions = Array.Empty<TeamPermissionValueDTO>()
}));
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<CancellationToken>()), 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<string> { "Dispatcher" });
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny<CancellationToken>())).ReturnsAsync(Array.Empty<string>());
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.Success(new TeamPermissionProfileDTO
{
UserId = user.Id,
Permissions = Array.Empty<TeamPermissionValueDTO>()
}));
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<string>(), It.IsAny<CancellationToken>()), Times.Never);
overrides.Verify(data => data.SetOverridesAsync(It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, UserPermissionState>>(), It.IsAny<CancellationToken>()), Times.Never);
userData.Verify(data => data.UpdateUserAsync(user, It.IsAny<CancellationToken>()), 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<string>()))
.ReturnsAsync((ApplicationUser?)null);
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
userManager
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
.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<Func<CancellationToken, Task>>(), It.IsAny<CancellationToken>()),
Times.Once);
userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()), Times.Never);
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), 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<string>()))
.ReturnsAsync((ApplicationUser?)null);
userManager
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
.Callback<ApplicationUser>(user =>
{
user.Id = "mid-fail-user";
created = user;
})
.ReturnsAsync(IdentityResult.Success);
userManager
.Setup(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), "Dispatcher"))
.ReturnsAsync(IdentityResult.Success);
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
areas
.Setup(data => data.ReplaceAsync(
"mid-fail-user",
It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "East", "West" })),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
overrides
.Setup(data => data.SetOverridesAsync(
"mid-fail-user",
It.IsAny<IReadOnlyDictionary<string, UserPermissionState>>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("injected mid-sequence failure"));
var failure = await Record.ExceptionAsync(() => service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None));
failure.Should().BeOfType<InvalidOperationException>()
.Which.Message.Should().Be("injected mid-sequence failure");
created.Should().NotBeNull();
areas.Verify(
data => data.ReplaceAsync("mid-fail-user", It.IsAny<IReadOnlyCollection<string>>(), It.IsAny<CancellationToken>()),
Times.Once);
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
private static TeamMemberService NewService(
out Mock<UserManager<ApplicationUser>> userManager,
out Mock<RoleManager<IdentityRole>> roleManager,
out Mock<IUserServiceAreaDataService> areas,
out Mock<ITeamPermissionOverrideDataService> overrides,
out Mock<IUserDataService> userData,
out Mock<ITeamPermissionService> permissions)
{
userManager = UserManager();
roleManager = RoleManager();
areas = new Mock<IUserServiceAreaDataService>();
overrides = new Mock<ITeamPermissionOverrideDataService>();
userData = new Mock<IUserDataService>();
permissions = new Mock<ITeamPermissionService>();
userData
.Setup(data => data.ExecuteTransactionalAsync(
It.IsAny<Func<CancellationToken, Task>>(),
It.IsAny<CancellationToken>()))
.Returns<Func<CancellationToken, Task>, CancellationToken>((callback, token) => callback(token));
return new TeamMemberService(
userManager.Object,
roleManager.Object,
areas.Object,
overrides.Object,
userData.Object,
permissions.Object,
Mock.Of<ITeamMemberInviteService>(),
Mock.Of<ISessionStampService>());
}
private static Mock<UserManager<ApplicationUser>> UserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object,
Microsoft.Extensions.Options.Options.Create(new IdentityOptions()),
Mock.Of<IPasswordHasher<ApplicationUser>>(),
Array.Empty<IUserValidator<ApplicationUser>>(),
Array.Empty<IPasswordValidator<ApplicationUser>>(),
Mock.Of<ILookupNormalizer>(),
new IdentityErrorDescriber(),
Mock.Of<IServiceProvider>(),
Mock.Of<ILogger<UserManager<ApplicationUser>>>());
}
private static Mock<RoleManager<IdentityRole>> RoleManager()
{
var store = new Mock<IRoleStore<IdentityRole>>();
return new Mock<RoleManager<IdentityRole>>(
store.Object,
Array.Empty<IRoleValidator<IdentityRole>>(),
Mock.Of<ILookupNormalizer>(),
new IdentityErrorDescriber(),
Mock.Of<ILogger<RoleManager<IdentityRole>>>());
}
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<string, UserPermissionState>
{
["deleteSites"] = UserPermissionState.Allow
}
};
private static ClaimsPrincipal Admin() =>
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
}

View file

@ -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<ApplicationDbContext>()
.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<string>
{
UserId = userId,
RoleId = "role-1"
});
}
}

View file

@ -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<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ClaimsPrincipal UserWithRoles(params string[] roles)
{
var claims = new List<Claim> { 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<Dispatch> 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<UpliftForbiddenException>();
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) { }
}
}

View file

@ -52,6 +52,70 @@ public class UpliftControllerTests
envelope.Status.Should().Be("Success");
}
[Fact]
public async Task List_PassesStatusAndTierFiltersToService()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Approved", 2, 3, 50, It.IsAny<CancellationToken>()))
.ReturnsAsync(new UpliftListResultDTO());
var controller = NewController(service);
await controller.List("Approved", 2, 3, 50);
service.Verify(
x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Approved", 2, 3, 50, It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task List_ReturnsQueueContractFieldsInEnvelope()
{
var service = new Mock<IUpliftService>();
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<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.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<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().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<Response>();
}
[Fact]
public async Task Revoke_WithReason_DelegatesToService()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.RevokeAsync(
It.IsAny<ClaimsPrincipal>(),
7,
"Policy change",
It.IsAny<CancellationToken>()))
.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<OkObjectResult>();
service.Verify(x => x.RevokeAsync(
It.IsAny<ClaimsPrincipal>(),
7,
"Policy change",
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Revoke_WithoutReason_ReturnsBadRequest()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.RevokeAsync(
It.IsAny<ClaimsPrincipal>(),
7,
string.Empty,
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("reason required"));
var controller = NewController(service, "Admin");
var result = await controller.Revoke(7, null);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally()
{

File diff suppressed because it is too large Load diff

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<ApplicationDbContext>()
.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<IActionResult> 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<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
workOrderFlow,
Mock.Of<IWorkOrderUpliftEvidenceService>(),
Mock.Of<ILogger<WorkOrderDetailController>>())
{
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<IVendorDocumentStoragePort>(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
workOrderFlow);
var approvals = new UpliftController(upliftService, Mock.Of<ILogger<UpliftController>>())
{
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<ObjectResult>().Subject;
refused.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
refused.Value.Should().BeOfType<Response>().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<BadRequestObjectResult>().Subject;
refused.Value.Should().BeOfType<Response>().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<OkObjectResult>();
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<BadRequestObjectResult>();
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<OkObjectResult>();
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);
}
}

View file

@ -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<IUpliftDataService>();
upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny<CancellationToken>()))
.ReturnsAsync(request);
upliftData.Setup(data => data.GetWorkOrderIdForUpliftAsync(7, It.IsAny<CancellationToken>()))
.ReturnsAsync(77);
var workOrderFlow = new Mock<IWorkOrderUpliftService>();
workOrderFlow.Setup(flow => flow.RevokeAsync(
77,
7,
It.Is<RevokeWorkOrderUpliftRequestDto>(r => r.Reason == "Policy change"),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderUpliftDto { Id = 7, Status = "revoked" });
var service = new UpliftService(
upliftData.Object,
Mock.Of<IDispatchDataService>(),
Mock.Of<IVendorDocumentStoragePort>(),
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<RevokeWorkOrderUpliftRequestDto>(r => r.Reason == "Policy change"),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest()
{

View file

@ -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<IUserDataService>(),
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<IUpliftDataService>();
upliftData.Setup(x => x.HasActiveAsync(dispatch.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
upliftData.Setup(x => x.StageAsync(It.IsAny<DispatchUpliftRequest>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException());
upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync(
It.IsAny<int>(),
It.IsAny<Func<CancellationToken, Task<DispatchUpliftRequest>>>(),
It.IsAny<CancellationToken>()))
.Returns((int _, Func<CancellationToken, Task<DispatchUpliftRequest>> 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<SeaHaven.Services.Exceptions.UpliftConflictException>();
}
// --- 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<KeyNotFoundException>();
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<InvalidOperationException>();
}
// 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<Task> 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<KeyNotFoundException>();
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<IUpliftDataService>();
upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DispatchUpliftRequest> { orphanReq, validReq });
upliftData.Setup(u => u.GetWorkOrderIdForUpliftAsync(202, It.IsAny<CancellationToken>()))
.ReturnsAsync(99);
upliftData.Setup(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()))
.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<InvalidOperationException>().WithMessage("*cancelled work order*");
context.DispatchUpliftRequests.Should().BeEmpty();
}
[Fact]
public async Task RequestUplift_WaitsForAWorkOrderCancelInFlightAndIsThenRefused()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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<InvalidOperationException>()
.WithMessage("*cancelled work order*");
using var verify = new ApplicationDbContext(options);
verify.DispatchUpliftRequests.Should().BeEmpty();
}
}

View file

@ -26,7 +26,8 @@ public class UserServiceTests
Mock<IUserDataService> userData,
Mock<IEmailSender> email,
out Mock<IUserRoleStore<ApplicationUser>> store,
Mock<IAccountDataService>? accounts = null)
Mock<IAccountDataService>? accounts = null,
Mock<ISessionStampService>? sessions = null)
{
var (manager, s, _) = IdentityTestHelpers.CreateUserManager();
store = s;
@ -34,7 +35,8 @@ public class UserServiceTests
manager,
userData.Object,
(accounts ?? new Mock<IAccountDataService>()).Object,
email.Object);
email.Object,
(sessions ?? new Mock<ISessionStampService>()).Object);
}
[Fact]
@ -90,6 +92,23 @@ public class UserServiceTests
userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DeleteUser_AccountOwner_ReturnsForbiddenWithoutCascade()
{
var user = IdentityTestHelpers.User("owner");
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(user);
userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(true);
var service = NewService(userData, new Mock<IEmailSender>(), 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<ApplicationUser>(), It.IsAny<CancellationToken>()), 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<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(user);
userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(true);
var service = NewService(userData, new Mock<IEmailSender>(), 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<ApplicationUser>(), It.IsAny<CancellationToken>()), 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<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(existing);
var accounts = new Mock<IAccountDataService>();
accounts.Setup(a => a.ExistsAsync(It.IsAny<int>())).ReturnsAsync(true);
var sessions = new Mock<ISessionStampService>();
var service = NewService(userData, new Mock<IEmailSender>(), out var store, accounts, sessions);
store.Setup(s => s.GetRolesAsync(existing, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "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<string>()), Times.Never);
}
}
}

View file

@ -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<UnauthorizedObjectResult>();
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_Returns200()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<UnauthorizedObjectResult>();
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<UnauthorizedObjectResult>();
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Patch_AddsTechnicians_Returns200WithRoster()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<ObjectResult>().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", controller.User, It.IsAny<CancellationToken>()), Times.Once);
}
}

View file

@ -835,4 +835,43 @@ public class VendorCompanyRosterDataServiceTests
await act.Should().ThrowAsync<OperationCanceledException>();
}
[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();
}
}

View file

@ -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<IVendorCompanyRosterDataService> 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<IVendorCompanyRosterDataService>();
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<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -60,7 +68,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
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<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone));
@ -78,7 +86,7 @@ public class VendorCompanyRosterServiceTests
Technicians = new List<RosterTechnicianInputDTO> { 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<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -98,7 +106,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>()
}, "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<ValidationException>())
.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<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), 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<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), 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<ValidationException>())
.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<ValidationException>())
.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<RosterTechnicianInputDTO> { 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<ValidationException>())
.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<ValidationException>())
.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<ValidationException>())
.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<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), 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<RosterTechnicianInputDTO> { 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<ValidationException>();
}
@ -462,7 +470,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
}, "42", cts.Token);
}, "42", Dispatcher, cts.Token);
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
}
@ -477,7 +485,7 @@ public class VendorCompanyRosterServiceTests
public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
{
var data = new Mock<IVendorCompanyRosterDataService>();
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<UnauthorizedAccessException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
@ -488,7 +496,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
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<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
@ -501,7 +509,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
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<ValidationException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -517,7 +525,7 @@ public class VendorCompanyRosterServiceTests
AddTechnicians = new List<RosterTechnicianInputDTO> { 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<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
@ -530,7 +538,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
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<ValidationException>())
.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<ValidationException>())
.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<ValidationException>();
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<VendorCompanyRosterAddWriteModel>(), 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<IVendorCompanyRosterDataService>();
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<VendorAreaAssignmentForbiddenException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny<CancellationToken>())).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<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId));
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AdminAssigningActiveArea_PersistsAreaId()
{
using var cts = new CancellationTokenSource();
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true);
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((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<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.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<VendorAreaAssignmentForbiddenException>();
}
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
var captured = new List<VendorCompanyRosterWriteModel>();
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((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<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((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<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_NonAdminAreaChange_IsForbidden()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.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<VendorAreaAssignmentForbiddenException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull()
{
var omitted = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
var cleared = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
var assigned = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"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);
}
}

View file

@ -39,7 +39,7 @@ public class VendorControllerTests
var service = new Mock<IVendorService>();
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<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once);
service.Verify(x => x.GetVendorCompanyDirectoryPagedAsync(
It.IsAny<int>(),
It.IsAny<int>(),
It.IsAny<string?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task GetVendorList_ReturnsIndividualTechnicianRowsWithoutGroupedTechnicians()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
It.IsAny<int>(),
It.IsAny<int>(),
It.IsAny<string?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
{
Items = new List<VendorDirectoryItemDTO>
{
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<OkObjectResult>().Subject;
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
var rows = ((System.Collections.IEnumerable)paged.Data!).Cast<object>().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<IVendorService>();
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
1,
500,
It.IsAny<string?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO> { TotalCount = 420, Page = 1, PageSize = 500 });
var result = await NewController(service).GetVendorList(page: 1, pageSize: 500);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
paged.PageSize.Should().Be(500);
paged.TotalPages.Should().Be(1);
service.VerifyAll();
}
[Fact]
public async Task GetVendorDirectoryList_DelegatesToCompanyDirectoryServiceWithNestedTechnicians()
{
var service = new Mock<IVendorService>();
using var cancellation = new CancellationTokenSource();
var token = cancellation.Token;
service.Setup(x => x.GetVendorCompanyDirectoryPagedAsync(
1,
10,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token))
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
{
Items = new List<VendorDirectoryItemDTO>
{
new()
{
Id = 9,
CompanyId = 3,
CompanyName = "Gateway Plumbing",
Email = "company@gatewayplumbing.example",
TotalJobs = 6,
Technicians = new List<VendorDirectoryTechnicianDTO>
{
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<OkObjectResult>().Subject;
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
var company = ((System.Collections.IEnumerable)paged.Data!).Cast<object>().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<object>().Select(t => t.GetType().GetProperty("Id")!.GetValue(t))
.Should().BeEquivalentTo(new[] { 7, 9 });
technicians.Cast<object>().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<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once);
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
It.IsAny<int>(),
It.IsAny<int>(),
It.IsAny<string?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public void VendorDirectoryEndpoints_AdvertiseDistinctCompatibilityAndCompanyRoutes()
{
var legacyRoutes = typeof(VendorController)
.GetMethod(nameof(VendorController.GetVendorList))!
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
.Cast<HttpGetAttribute>()
.Select(attribute => attribute.Template);
var companyRoutes = typeof(VendorController)
.GetMethod(nameof(VendorController.GetVendorDirectoryList))!
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
.Cast<HttpGetAttribute>()
.Select(attribute => attribute.Template);
legacyRoutes.Should().BeEquivalentTo("GetVendorList");
companyRoutes.Should().BeEquivalentTo("GetVendorDirectoryList");
}
[Fact]

View file

@ -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<Dispatch> { 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<Dispatch> { 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<Dispatch> { new() { WorkOrderId = 201 } }
};
var newer = new Vendor
{
Company = company,
CompanyName = company.Name,
ContactName = "Zed HVAC",
TradeSpecialties = "HVAC",
IsActive = true,
IsDeleted = false,
Dispatches = new List<Dispatch> { new() { WorkOrderId = 202 } }
};
var inactive = new Vendor
{
Company = company,
CompanyName = company.Name,
ContactName = "Inactive Tech",
TradeSpecialties = "Roofing",
IsActive = false,
IsDeleted = false,
Dispatches = new List<Dispatch> { 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");
}
}

View file

@ -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<BadRequestObjectResult>();
}
[Fact]
public async Task RequestUplift_ConcurrentActiveRequest_ReturnsConflict()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.RequestUpliftAsync(
Session,
10,
1500m,
"Need more parts",
It.IsAny<string?>(),
It.IsAny<int?>(),
It.IsAny<CancellationToken>()))
.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<ConflictObjectResult>().Subject;
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value.Should().NotBeNull();
}
[Fact]
public async Task RequestUplift_Success_ReturnsResult()
{

View file

@ -54,6 +54,11 @@ public sealed class VendorPortalDocumentTests : IDisposable
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalUpliftData>());
upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync(
It.IsAny<int>(),
It.IsAny<Func<CancellationToken, Task<VendorCompletionDocument>>>(),
It.IsAny<CancellationToken>()))
.Returns((int _, Func<CancellationToken, Task<VendorCompletionDocument>> work, CancellationToken ct) => work(ct));
var commentData = new Mock<ICommentDataService>();
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalCommentData>());
@ -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<OkObjectResult>();
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<OkObjectResult>();
(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<OkObjectResult>();
(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<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
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<OkObjectResult>();
}
else
{
result.Should().BeOfType<BadRequestObjectResult>();
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<BadRequestObjectResult>();
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<OkObjectResult>();
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<BadRequestObjectResult>();
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<BadRequestObjectResult>();
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<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata()
{

View file

@ -152,12 +152,12 @@ public class VendorServiceTests
}
[Fact]
public async Task GetVendorDirectoryPaged_ForwardsCancellationToken()
public async Task GetVendorCompanyDirectoryPaged_ForwardsCancellationTokenAndMapsNestedTechnicians()
{
var data = new Mock<IVendorDataService>();
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<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
token))
.ReturnsAsync((Array.Empty<VendorDirectoryRow>(), 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<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
token), Times.Once);
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_BoundsPageInputsBeforeBuildingTheGroupKeyPredicate()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
1,
100,
null,
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<VendorCompanyGroup>(), 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<IVendorDataService>();
using var cancellation = new CancellationTokenSource();
var token = cancellation.Token;
data.Setup(x => x.GetDirectoryPagedAsync(
2,
500,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
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<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once);
}
@ -657,6 +759,8 @@ public class VendorServiceTests
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
@ -695,6 +799,8 @@ public class VendorServiceTests
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
@ -723,6 +829,8 @@ public class VendorServiceTests
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
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<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
@ -799,4 +909,96 @@ public class VendorServiceTests
TradeCatalog.CanonicalTrades,
opts => opts.WithStrictOrdering());
}
private static Mock<IVendorDataService> DirectoryDataWithAreas(Action<VendorAreaFilter?> capture)
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new() { Id = 1, Name = "East", NormalizedName = "east" },
new() { Id = 3, Name = "West", NormalizedName = "west" }
});
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
It.IsAny<int>(),
It.IsAny<int>(),
It.IsAny<string?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
It.IsAny<CancellationToken>()))
.Callback<int, int, string?, bool?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, VendorAreaFilter?, CancellationToken>(
(_, _, _, _, _, _, _, _, filter, _) => capture(filter))
.ReturnsAsync((new List<VendorCompanyGroup>(), 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<CancellationToken>()), Times.Never);
}
[Fact]
public async Task GetFacetsAsync_AreasComeFromActiveCatalogueOrderedByName()
{
using var cts = new CancellationTokenSource();
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany>());
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(cts.Token))
.ReturnsAsync(new List<Area>
{
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);
}
}

View file

@ -23,6 +23,7 @@ public class WorkOrderControllerSearchTests
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
Mock.Of<IWorkOrderPocService>(),
advancedSearch);
controller.ControllerContext = new ControllerContext

View file

@ -0,0 +1,68 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// 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.
/// </summary>
public class WorkOrderDataServiceTimestampTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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);
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<IActionDescriptorCollectionProvider>().ActionDescriptors.Items
.OfType<ControllerActionDescriptor>()
.Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderController))
.Where(cad => cad.ActionConstraints!
.OfType<Microsoft.AspNetCore.Mvc.ActionConstraints.HttpMethodActionConstraint>()
.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<IWorkOrderService>();
service.Setup(s => s.GetWorkOrderDetailAsync(ExistingId, It.IsAny<int?>())).ReturnsAsync(existing);
service.Setup(s => s.GetWorkOrderDetailAsync(UnknownId, It.IsAny<int?>()))
.ReturnsAsync((WorkOrderDetailReadModel?)null);
var controller = new WorkOrderController(
service.Object,
Mock.Of<IWorkOrderAccountResolver>(),
Mock.Of<ILogger<WorkOrderController>>())
{
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<OkObjectResult>().Which.Value.Should().BeSameAs(existing);
byRoute.Should().BeOfType<OkObjectResult>().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<BadRequestObjectResult>().Which.Value
.Should().BeOfType<Response>().Subject;
body.Status.Should().Be("Error");
body.Message.Should().Be("ID not found!");
}
}
}

View file

@ -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;
/// <summary>
/// GET /board/search?ids= shows exactly the listed work orders inside the caller's tenant scope,
/// whatever other filters the board sends alongside.
/// </summary>
public class WorkOrderIdsFilterTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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> { LifecycleStatus.Scheduled },
Dispatchers = new List<string> { "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<string> { "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<BadRequestObjectResult>().Subject;
badRequest.Value.Should().BeOfType<Response>().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<BadRequestObjectResult>()
.Which.Value.Should().BeOfType<Response>()
.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<SeaHaven.DataServices.Interfaces.IWorkOrderAdvancedSearchDataService>(),
Mock.Of<IWorkOrderAccountResolver>());
return new WorkOrderBoardController(
Mock.Of<IWorkOrderBoardService>(),
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
Mock.Of<IWorkOrderPocService>(),
search)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = AccountUser(1) }
}
};
}
}

View file

@ -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<IFormFile>();
file.SetupGet(candidate => candidate.Length).Returns(50_000_001);
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
var attribute = Assert.IsType<RequestFormLimitsAttribute>(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<IFileStoragePort>(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<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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];
/// <summary>
/// A file that reports <paramref name="length"/> bytes but only backs the 512-byte header the
/// type rules read, so oversize cases run through real signature validation cheaply.
/// </summary>
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<IWorkOrderMediaService> Service, Mock<IFileStoragePort> Storage) SetupSuccessfulAddMedia()
{
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
service
.Setup(candidate => candidate.EnsureCanMutateMediaAsync(
1, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), null))
.Returns(Task.CompletedTask);
service
.Setup(candidate => candidate.AddMediaAsync(
1, null, "https://storage.test/stored", It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderMediaFileDto { Id = 5, Url = "https://storage.test/stored" });
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
storage
.Setup(candidate => candidate.SaveFileAsync(It.IsAny<IFormFile>()))
.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<OkObjectResult>(result);
Assert.Equal(5, Assert.IsType<WorkOrderMediaFileDto>(ok.Value).Id);
}
[Fact]
public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity()
{
var storage = new Mock<IFileStoragePort>(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<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<OkObjectResult>(result);
}
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity()
{
@ -157,7 +425,8 @@ public class WorkOrderMediaServiceCancellationTests
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
storage.Object);
storage.Object,
new Mock<IUpliftDataService>(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<IWorkOrderMediaDataService>(MockBehavior.Strict);
mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrder { Id = 1 });
mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url });
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3]));
var service = new WorkOrderMediaService(
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
storage.Object,
new Mock<IUpliftDataService>(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);
}
}

View file

@ -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.
/// </summary>
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).
/// </summary>
private static readonly HashSet<string> 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",

View file

@ -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<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.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<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
var response = forbidden.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
[Fact]
public async Task CreateUplift_UnrelatedForbiddenException_ReturnsSanitized403()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.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<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
var response = forbidden.Value.Should().BeOfType<Response>().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<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.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<ConflictObjectResult>().Subject;
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value.Should().NotBeNull();
}
[Fact]
public async Task CancelUplift_NotFound_Returns404()
{

View file

@ -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<DispatchWorkOrder>
{
new() { DispatchId = 10, WorkOrderId = 2 },
},
};
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(x => x.GetUpliftDispatchForWorkOrderAsync(
It.IsIn(1, 2),
10,
It.IsAny<CancellationToken>()))
.ReturnsAsync(sharedDispatch);
var bothAtInsert = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var savesArrived = 0;
var winner = 0;
upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync(
It.IsAny<int>(),
It.IsAny<Func<CancellationToken, Task<WorkOrderUpliftDto?>>>(),
It.IsAny<CancellationToken>()))
.Returns<int, Func<CancellationToken, Task<WorkOrderUpliftDto?>>, CancellationToken>(
(_, work, cancellationToken) => work(cancellationToken));
upliftData.Setup(x => x.HasPendingForWorkOrderAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
upliftData.Setup(x => x.HasActiveAsync(10, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
upliftData.Setup(x => x.SumAutoApprovedAmountForWorkOrderAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(0m);
upliftData.Setup(x => x.StageAsync(It.IsAny<DispatchUpliftRequest>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
.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<IWorkOrderDetailDataService>();
detailData.Setup(x => x.ExistsAsync(It.IsAny<int>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
.ReturnsAsync(true);
detailData.Setup(x => x.GetWorkOrderForMediaAsync(It.IsAny<int>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
.ReturnsAsync((int workOrderId, CancellationToken _, int? _) => new WorkOrder
{
Id = workOrderId,
PrimaryDispatchId = 10,
WorkOrderType = WorkOrderType.PM,
LifecycleStatus = LifecycleStatus.Scheduled,
});
var dispatchData = new Mock<IDispatchDataService>();
dispatchData.Setup(x => x.GetByIdAsync(10))
.ReturnsAsync(sharedDispatch);
dispatchData.Setup(x => x.StageAuditLogAsync(It.IsAny<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var accountResolver = new Mock<IWorkOrderAccountResolver>();
accountResolver.Setup(x => x.ResolveAccountFilter(It.IsAny<ClaimsPrincipal>())).Returns((int?)null);
var userData = new Mock<IUserDataService>();
userData.Setup(x => x.GetDisplayNamesByIdsAsync(It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(new Dictionary<string, string>());
// SH-327: the requester must hold RequestUplifts before the create reaches the insert.
var permissionData = new Mock<ITeamPermissionOverrideDataService>();
permissionData.Setup(x => x.GetUserAsync("dispatcher-1", It.IsAny<CancellationToken>()))
.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<IVendorDocumentDataService>().Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") },
"test"));
static async Task<(WorkOrderUpliftDto? Result, Exception? Error)> Capture(
Func<Task<WorkOrderUpliftDto?>> 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<UpliftConflictException>(Assert.Single(outcomes, outcome => outcome.Error != null).Error);
upliftData.Verify(x => x.HasActiveAsync(10, It.IsAny<CancellationToken>()), Times.Exactly(2));
}
}

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> VerificationCode(string code, CancellationToken cancellationToken)
[EnableRateLimiting(PasswordResetRateLimiting.ForgetPasswordPolicy)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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
}
}

View file

@ -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<IActionResult> GetStats(CancellationToken cancellationToken)
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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
});
}
}
}
}

View file

@ -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<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<SiteContactRequestDTO>? MapSiteContacts(List<SiteContactInput_DTO>? contacts)
{
if (contacts == null)
return null;
return contacts
.Select(c => new SiteContactRequestDTO { Id = c.Id, Name = c.Name, Phone = c.Phone })
.ToList();
}
}
}

View file

@ -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;
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("sla/{workOrderId:int}/acknowledge")]
public async Task<IActionResult> 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<IActionResult> 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
});
}
}
}
}

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
}
}

View file

@ -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<IActionResult> GetMyPermissions(CancellationToken cancellationToken)
{
var result = await _permissionService.GetEffectivePermissionsAsync(User, cancellationToken);
return result.IsSuccess ? Ok(result.Value) : Unauthorized();
}
[HttpPost("{userId}/invite")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Get(string userId, CancellationToken cancellationToken)
{
var outcome = await _teamMemberService.GetAsync(userId, User, cancellationToken);
return ToActionResult(outcome);
}
[HttpPut("{userId}")]
public async Task<IActionResult> 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 });
}
}

View file

@ -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;
/// <summary>
/// Anonymous invite registration. The invite token travels only in request bodies,
/// and every failure maps to a fixed public message.
/// </summary>
[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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
}

View file

@ -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<IActionResult> GetProfile(
string userId,
CancellationToken cancellationToken)
{
var result = await _permissionService.GetProfileAsync(userId, User, cancellationToken);
return ToActionResult(result);
}
[HttpPut("{permissionKey}")]
public async Task<IActionResult> 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<TeamPermissionProfileDTO> 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." })
};
}
}

View file

@ -127,6 +127,35 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpPost("{id:int}/revoke")]
public async Task<IActionResult> 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)
{

View file

@ -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" });

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId)

View file

@ -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<IActionResult> UploadCompletionDocument(
int id,
[FromForm] IFormFile file,

View file

@ -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
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPatch("{id:int}/poc")]
public async Task<IActionResult> 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<IActionResult> CreateBoardWorkOrder(
[FromBody] WorkOrderBoardCreateRequestDto request,

View file

@ -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<IActionResult> 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<IActionResult> GetCompletionTemplate(int id)
public async Task<IActionResult> 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<IActionResult> CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request)
[HttpGet("completion-templates/{id:int}/linked-work-orders")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request)
public async Task<IActionResult> 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<IActionResult> DeleteCompletionTemplate(int id)
public async Task<IActionResult> 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<IActionResult> UploadCompletionDoc(

View file

@ -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<IActionResult> GetWorkorderById(int id)
public Task<IActionResult> GetWorkorderById([FromQuery] int id) => GetWorkorderDetail(id);
[HttpGet("{id:int}")]
public Task<IActionResult> GetWorkorderByRouteId([FromRoute] int id) => GetWorkorderDetail(id);
private async Task<IActionResult> GetWorkorderDetail(int id)
{
try
{

View file

@ -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)
{

View file

@ -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<IActionResult> 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);

View file

@ -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<SiteContactInput_DTO>? Contacts { get; set; }
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);

View file

@ -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; }
}
}

View file

@ -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; }
}
/// <summary>Contacts and notes edited from the work-order Site dialog.</summary>
public class SiteContactInfoInput_DTO
{
public List<SiteContactInput_DTO>? 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<SiteContactInput_DTO>? Contacts { get; set; }
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);

View file

@ -0,0 +1,24 @@
using SeaHaven.Services.DTOs;
namespace Api.SeaHavenIndustries.DTOs
{
/// <summary>The session payload the web app stores after sign-in.</summary>
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
};
}
}
}

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
public sealed class InviteRegistrationExceptionFilter : IExceptionFilter
{
public const string Message = "Something went wrong. Try again in a minute.";
private readonly ILogger<InviteRegistrationExceptionFilter> _logger;
public InviteRegistrationExceptionFilter(ILogger<InviteRegistrationExceptionFilter> 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;
}
}
}

View file

@ -9,14 +9,37 @@ namespace Api.SeaHavenIndustries.Helper
public class SendMessage : IEmailSender
{
private IConfiguration _configuration;
private readonly ILogger<SendMessage> _logger;
//string keysapi = "";
public SendMessage(IConfiguration configuration)
public SendMessage(IConfiguration configuration, ILogger<SendMessage> logger)
{
_configuration = configuration;
_logger = logger;
//keysapi = _configuration.GetValue<string>("SendGrid:ApiKey");
}
protected virtual ISendGridClient CreateClient(string? apiKey) => new SendGridClient(apiKey);
/// <summary>
/// SendGrid reports a rejected message through the status code, not an exception.
/// Only the status is logged: never the recipient, subject or response body.
/// </summary>
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<bool> SendEMail(string emailTo, string subject, string body)
{
try
@ -41,7 +64,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
//var apiKey = "<SENDGRID_API_KEY>";
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<bool> SendEmailAsync(string emailTo, string subject, string htmlBody)
@ -69,7 +91,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
//var apiKey = "<SENDGRID_API_KEY>";
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<SendGrid.Helpers.Mail.Attachment> { 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<bool> SendDispatchEmail(string emailTo, string subject, string htmlBody, string? replyTo)
@ -106,7 +126,7 @@ namespace Api.SeaHavenIndustries.Helper
try
{
var apiKey = _configuration.GetValue<string>("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);
}
}
}

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
public static IServiceCollection AddPasswordResetEmailDelivery(this IServiceCollection services)
{
services.AddSingleton<PasswordResetEmailChannel>();
services.AddSingleton<IPasswordResetEmailQueue>(provider => provider.GetRequiredService<PasswordResetEmailChannel>());
services.AddHostedService<PasswordResetEmailSenderHostedService>();
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<PasswordResetEmail> _channel = Channel.CreateBounded<PasswordResetEmail>(
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<PasswordResetEmailChannel> _logger;
private int _pending;
public PasswordResetEmailChannel(ILogger<PasswordResetEmailChannel> logger)
{
_logger = logger;
}
public ChannelReader<PasswordResetEmail> Reader => _channel.Reader;
/// <summary>Emails accepted and not yet handed to the mail provider.</summary>
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<PasswordResetEmailSenderHostedService> _logger;
private readonly IHub _sentryHub;
public PasswordResetEmailSenderHostedService(
PasswordResetEmailChannel channel,
IServiceScopeFactory scopeFactory,
ILogger<PasswordResetEmailSenderHostedService> 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();
}
}
}
/// <summary>True when the mail provider accepted the email.</summary>
private async Task<bool> SendAsync(PasswordResetEmail email)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
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;
}
}
}

View file

@ -0,0 +1,24 @@
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Identity;
namespace Api.SeaHavenIndustries.Infrastructure
{
public static class IdentityRegistration
{
/// <summary>
/// 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.
/// </summary>
public static IdentityBuilder AddSeaHavenIdentity(this IServiceCollection services)
{
return services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.User.RequireUniqueEmail = false;
IdentityPasswordPolicy.Apply(options.Password);
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
}
}
}

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<ISessionStampService>();
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.");
}
}
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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.";
/// <summary>
/// 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.
/// </summary>
public static IServiceCollection AddPasswordResetRateLimiting(this IServiceCollection services)
{
services.Configure<ForwardedHeadersOptions>(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
}));
}
}

View file

@ -29,21 +29,46 @@ public static class SentryObservability
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.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)
{

View file

@ -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<ApplicationDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.User.RequireUniqueEmail = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.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<SeaHaven.Services.Implementation.WorkOrderOpsHealthO
health.LegacySunsetDate = legacy.Value.SunsetDate;
});
builder.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")))
};
});
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<LegacyDeprecationMiddleware>();
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
app.UseMiddleware<SentryRequestMetadataMiddleware>();
app.UseAuthorization();
app.MapControllers();
using (var ownerScope = app.Services.CreateScope())
{
await ownerScope.ServiceProvider
.GetRequiredService<IAccountOwnerDesignationService>()
.EnsureConfiguredOwnerAsync(CancellationToken.None);
}
//if (app.Environment.IsDevelopment())
//{
// using var scope = app.Services.CreateScope();

View file

@ -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<T>` 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.

View file

@ -54,6 +54,23 @@ namespace Data.SeaHavenIndustries
builder.Entity<CompletionDocTemplate>()
.HasIndex(t => new { t.ServiceKey, t.IsActive });
builder.Entity<CompletionDocTemplate>()
.Property(t => t.ExtraSafetyNote)
.HasMaxLength(CompletionDocTemplate.ExtraSafetyNoteMaxLength);
builder.Entity<CompletionDocTemplateProcedure>(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<WorkOrder>()
.Property(w => w.RowVersion)
@ -80,6 +97,12 @@ namespace Data.SeaHavenIndustries
.HasIndex(w => w.InternalWONumber)
.HasFilter("[istemplate] = 0");
builder.Entity<WorkOrder>()
.HasOne(w => w.ServiceDefinition)
.WithMany()
.HasForeignKey(w => w.ServiceId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<WorkOrder>()
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
.HasFilter("[istemplate] = 0");
@ -170,6 +193,12 @@ namespace Data.SeaHavenIndustries
builder.Entity<ApplicationUser>()
.HasIndex(u => u.AccountId);
builder.Entity<ApplicationUser>()
.HasIndex(u => u.IsAccountOwner)
.IsUnique()
.HasFilter("IsAccountOwner = 1")
.HasDatabaseName("IX_AspNetUsers_IsAccountOwner");
builder.Entity<VendorCompany>()
.HasIndex(c => c.NormalizedName)
.IsUnique();
@ -206,6 +235,133 @@ namespace Data.SeaHavenIndustries
builder.Entity<Trade>()
.HasIndex(t => t.NormalizedName)
.IsUnique();
// Bounded lengths keep the unique NormalizedName key SQL
// Server-indexable (nvarchar(max) is not).
builder.Entity<Service>()
.Property(s => s.Name)
.HasMaxLength(200);
builder.Entity<Service>()
.Property(s => s.NormalizedName)
.HasMaxLength(200);
builder.Entity<Service>()
.HasIndex(s => s.NormalizedName)
.IsUnique();
builder.Entity<Service>()
.Property(s => s.Trade)
.HasMaxLength(64);
builder.Entity<Service>()
.HasIndex(s => s.Trade);
builder.Entity<Service>()
.Property(s => s.IconKey)
.HasMaxLength(64);
builder.Entity<Service>()
.HasIndex(s => s.IsActive);
builder.Entity<Service>()
.HasOne(s => s.CompletionDocTemplate)
.WithMany()
.HasForeignKey(s => s.CompletionDocTemplateId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<Service>()
.HasIndex(s => s.CompletionDocTemplateId);
builder.Entity<ServiceWorkOrderType>()
.HasIndex(t => new { t.ServiceId, t.WorkOrderType })
.IsUnique();
builder.Entity<ServiceWorkOrderType>()
.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<Area>()
.Property(a => a.Name)
.HasMaxLength(128);
builder.Entity<Area>()
.Property(a => a.NormalizedName)
.HasMaxLength(128);
builder.Entity<Area>()
.HasIndex(a => a.NormalizedName)
.IsUnique();
builder.Entity<VendorCompany>()
.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<UserPermissionOverride>(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<TeamMemberInvite>(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<UserServiceArea>(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<Category> Categories { get; set; }
public DbSet<Locations> Locations { get; set; }
@ -220,6 +376,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderCategories> workOrderCategories { get; set; }
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
public DbSet<CompletionDocTemplate> CompletionDocTemplates { get; set; }
public DbSet<CompletionDocTemplateProcedure> CompletionDocTemplateProcedures { get; set; }
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
@ -228,6 +385,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Trade> Trades { get; set; }
public DbSet<Area> Areas { get; set; }
public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorCompany> VendorCompanies { get; set; }
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
@ -241,6 +399,8 @@ namespace Data.SeaHavenIndustries
public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; }
public DbSet<TaskListTemplate> TaskListTemplates { get; set; }
public DbSet<TaskListTemplateItem> TaskListTemplateItems { get; set; }
public DbSet<Service> Services { get; set; }
public DbSet<ServiceWorkOrderType> ServiceWorkOrderTypes { get; set; }
// New DbSets for added entities
public DbSet<Accounts> Accounts { get; set; }
@ -259,6 +419,9 @@ namespace Data.SeaHavenIndustries
public DbSet<Department> Departments { get; set; }
public DbSet<JobTitle> JobTitles { get; set; }
public DbSet<Region> Regions { get; set; }
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
public DbSet<UserServiceArea> UserServiceAreas { get; set; }
public DbSet<TeamMemberInvite> 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; }
/// <summary>Optional CRM account membership for server-derived media scope (SH-221).</summary>
public int? AccountId { get; set; }
[ForeignKey(nameof(AccountId))]
public virtual Accounts? Account { get; set; }
public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? WorkOrders { get; set; }
public ICollection<UserServiceArea> ServiceAreas { get; set; } = new List<UserServiceArea>();
}
public class FullAuditEntity
{

View file

@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Identity;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// The single password rule for every surface that sets a password: at least
/// six characters with one uppercase letter, one number, and one special
/// character. Lowercase letters are deliberately not required so the server
/// accepts exactly what the four-item checklist in the web app marks as met.
/// </summary>
public static class IdentityPasswordPolicy
{
public const int MinimumLength = 6;
public static void Apply(PasswordOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.RequiredLength = MinimumLength;
options.RequireUppercase = true;
options.RequireDigit = true;
options.RequireNonAlphanumeric = true;
options.RequireLowercase = false;
options.RequiredUniqueChars = 1;
}
private static readonly HashSet<string> PolicyErrorCodes = new(StringComparer.Ordinal)
{
nameof(IdentityErrorDescriber.PasswordTooShort),
nameof(IdentityErrorDescriber.PasswordRequiresUpper),
nameof(IdentityErrorDescriber.PasswordRequiresLower),
nameof(IdentityErrorDescriber.PasswordRequiresDigit),
nameof(IdentityErrorDescriber.PasswordRequiresNonAlphanumeric),
nameof(IdentityErrorDescriber.PasswordRequiresUniqueChars)
};
/// <summary>
/// True when Identity refused the password itself. Other failures, such as a
/// concurrency conflict, must not be reported to the user as a weak password.
/// </summary>
public static bool IsPolicyRejection(IdentityResult result)
{
ArgumentNullException.ThrowIfNull(result);
return result.Errors.Any(error => PolicyErrorCodes.Contains(error.Code));
}
}
}

View file

@ -0,0 +1,18 @@
using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// Per-person team permission override, keyed by user and canonical
/// permission key. Explicit Allow/Deny rows take precedence over role defaults;
/// <see cref="UserPermissionState.Unset"/> (and a missing row) falls back to
/// the role default.
/// </summary>
public class UserPermissionOverride
{
public string UserId { get; set; } = null!;
public string PermissionKey { get; set; } = null!;
public UserPermissionState State { get; set; } = UserPermissionState.Unset;
public virtual ApplicationUser? User { get; set; }
}
}

Some files were not shown because too many files have changed in this diff Show more