mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 19:03:22 +00:00
Compare commits
317 commits
31d4352a23
...
2f4b783615
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f4b783615 | ||
|
|
31dd2d5dcd | ||
|
|
d3b6ba01c7 | ||
|
|
74d5aa17eb | ||
|
|
1077d489a8 | ||
|
|
e941e055b7 | ||
|
|
c985e9423d | ||
|
|
de0e767930 | ||
|
|
63465cc083 | ||
|
|
a32471d4c0 | ||
|
|
0298fc75bd | ||
|
|
498f49a2d8 | ||
|
|
987ec455f2 | ||
|
|
de8e283ee5 | ||
|
|
410bf1b4ca | ||
|
|
c8e4b8f3d5 | ||
|
|
77dc3d85c3 | ||
|
|
cc46950254 | ||
|
|
b7be07411e | ||
|
|
306ab159cc | ||
|
|
e993e1b5fc | ||
|
|
99499c3281 | ||
|
|
9bad363930 | ||
|
|
ca9322fa60 | ||
|
|
2f23f6fadc | ||
|
|
185c84dd8c | ||
|
|
35e79a276d | ||
|
|
c860936d07 | ||
|
|
9198c5cd5d | ||
|
|
92cd44c481 | ||
|
|
5eb1323419 | ||
|
|
f0dcba63fd | ||
|
|
cf32dd2698 | ||
|
|
c6dcad8395 | ||
|
|
1277642ede | ||
|
|
900e141bda | ||
|
|
9084b7f642 | ||
|
|
fc3a29f399 | ||
|
|
fe5f27c8e1 | ||
|
|
613bfa48d8 | ||
|
|
ea1370847f | ||
|
|
9bd22e9a74 | ||
|
|
47060f6a73 | ||
|
|
bb41bfd8ed | ||
|
|
57af8a1206 | ||
|
|
77e54e64e3 | ||
|
|
a8d05776a8 | ||
|
|
5353899418 | ||
|
|
cc328ce22a | ||
|
|
a6dd40b972 | ||
|
|
afc2330184 | ||
|
|
e53415de39 | ||
|
|
9091335ff2 | ||
|
|
77a10e38ca | ||
|
|
b50cd5f5df | ||
|
|
f491d4c721 | ||
|
|
e09ef061d1 | ||
|
|
561873a797 | ||
|
|
e9a1d8f53f | ||
|
|
3249ea4b6f | ||
|
|
0088cffd47 | ||
|
|
bcc9b6d7a2 | ||
|
|
236199ab7a | ||
|
|
85b7d1e1c7 | ||
|
|
c841e130be | ||
|
|
1c8da7f344 | ||
|
|
385229c64d | ||
|
|
227691269d | ||
|
|
27c21ec32e | ||
|
|
6bfb56f349 | ||
|
|
13fec977fa | ||
|
|
c8073123e3 | ||
|
|
92dabbfbe3 | ||
|
|
f9cdbaa06b | ||
|
|
c0ae8479ce | ||
|
|
c1910e5310 | ||
|
|
a224f883bc | ||
|
|
ed5c75223e | ||
|
|
d82fb18a3d | ||
|
|
60b1afd8e0 | ||
|
|
cd23ad5b68 | ||
|
|
c3865e56ac | ||
|
|
14c8e46dd0 | ||
|
|
66a49ab957 | ||
|
|
7c097c2750 | ||
|
|
b545d4a4fe | ||
|
|
3019e71093 | ||
|
|
06eae2fb02 | ||
|
|
702069f09c | ||
|
|
d6c5357fab | ||
|
|
c49a98db18 | ||
|
|
2c8ffaf10e | ||
|
|
c1ed5dc98d | ||
|
|
207bf59208 | ||
|
|
203fc92e4a | ||
|
|
e02f9774dc | ||
|
|
3b69b9fbcc | ||
|
|
85f2e709c9 | ||
|
|
d33ba34db9 | ||
|
|
eb2b442775 | ||
|
|
f40ad7c1ef | ||
|
|
67cd9c589b | ||
|
|
0d8f32d148 | ||
|
|
50e5553e57 | ||
|
|
b2b9fc3588 | ||
|
|
59b8cdaf7d | ||
|
|
c5d82a996a | ||
|
|
8185875ad2 | ||
|
|
f3ef11b504 | ||
|
|
beb091fcc8 | ||
|
|
1e2f39a5fa | ||
|
|
3e3f0f383d | ||
|
|
7591869462 | ||
|
|
26b4aaf1b2 | ||
|
|
cfb05a906f | ||
|
|
b116e71d16 | ||
|
|
cc7cda4818 | ||
|
|
2be74b261f | ||
|
|
24ad21d7f2 | ||
|
|
eecc2a61bd | ||
|
|
7b7df4463e | ||
|
|
76b13fb5da | ||
|
|
7297e30212 | ||
|
|
ce3b241991 | ||
|
|
fd59a3da13 | ||
|
|
a8414cd4fa | ||
|
|
e15de6e90b | ||
|
|
89376e99e4 | ||
|
|
16845a55f3 | ||
|
|
07bc81cc52 | ||
|
|
4876fa2717 | ||
|
|
9d5798437d | ||
|
|
dc251c42d4 | ||
|
|
028908bd5a | ||
|
|
fddb7b7909 | ||
|
|
5aa3a6f820 | ||
|
|
3b626cca58 | ||
|
|
0ca9b767ed | ||
|
|
8af9ad076f | ||
|
|
0b39d92723 | ||
|
|
f7db6b9f84 | ||
|
|
50a2cbab5f | ||
|
|
e1632bc3bf | ||
|
|
f4284badf2 | ||
|
|
ff6a5945f1 | ||
|
|
9156107e72 | ||
|
|
5c5c01b2e8 | ||
|
|
adb192cad7 | ||
|
|
d282799127 | ||
|
|
b36b69df83 | ||
|
|
06b88b6006 | ||
|
|
a615be28e5 | ||
|
|
ad5d58437c | ||
| 1188a4c1cb | |||
|
|
03a0a6514d | ||
|
|
0f6b72e68a | ||
|
|
3db433b0bf | ||
|
|
752f5fdea1 | ||
|
|
ae35f10921 | ||
| 38588ac33e | |||
|
|
637ba3350b | ||
| 24e4f2b7f7 | |||
|
|
019eb86894 | ||
| b7b22a8893 | |||
|
|
10266e6e4b | ||
|
|
e1ce3e439b | ||
|
|
0b3084a274 | ||
|
|
cd4d30af4b | ||
|
|
a9773e67eb | ||
| b270543d0d | |||
|
|
1888b66940 | ||
|
|
582af8fb2c | ||
|
|
51417a65b5 | ||
|
|
503faa8c25 | ||
|
|
1cd3c802e3 | ||
|
|
0556822d22 | ||
|
|
4fb4159df2 | ||
|
|
46eed22707 | ||
|
|
9eb51b528f | ||
|
|
facc6ef71e | ||
|
|
cbecc7b4ea | ||
|
|
90303f0e40 | ||
| b696a414a5 | |||
| f42576e2db | |||
|
|
9784dcf895 | ||
|
|
e668e13912 | ||
|
|
c9fa4a49af | ||
|
|
aad3facaf1 | ||
|
|
ad68bccb89 | ||
|
|
68ad7362df | ||
|
|
fa979605b4 | ||
| 23f69307dd | |||
|
|
e74fff7722 | ||
|
|
60a3fcc308 | ||
|
|
da0b29f769 | ||
| 396b1c690b | |||
| 7eaa7fb3f9 | |||
| 8f4fa36647 | |||
|
|
63f80990cf | ||
|
|
8dfb01cba7 | ||
|
|
d02aa11285 | ||
|
|
b9bcaea9f3 | ||
|
|
5e70163941 | ||
|
|
455ff5568c | ||
|
|
6db9ad758f | ||
|
|
8a4de283dc | ||
|
|
0dc9ec288e | ||
| 1a290ea2f7 | |||
|
|
bc88ef0577 | ||
|
|
2657be3108 | ||
|
|
1f905fc7dd | ||
|
|
45519c1a82 | ||
|
|
7b94ab9455 | ||
|
|
41957d52a7 | ||
|
|
0ae64f9ab9 | ||
|
|
046dee99bf | ||
|
|
679822733a | ||
|
|
cc9a5ade21 | ||
|
|
f4860a3dd9 | ||
|
|
ef371b5917 | ||
|
|
3e301ac9da | ||
|
|
1eadb82f28 | ||
|
|
ba8907fad0 | ||
|
|
9388e7a987 | ||
|
|
468522c3f7 | ||
|
|
564bd70301 | ||
|
|
146fe6fdb0 | ||
|
|
5c93e4ecec | ||
|
|
f564e1b112 | ||
|
|
daf3ed9210 | ||
|
|
782165a4cf | ||
|
|
7e06d1c51b | ||
|
|
e07f04a6af | ||
|
|
b10956d731 | ||
|
|
7d728d9101 | ||
|
|
da80595321 | ||
|
|
2e983b1f6a | ||
|
|
039a9c8d7e | ||
|
|
dfd248cfcb | ||
|
|
e0ec8dbc73 | ||
|
|
fbae09136b | ||
|
|
1b73593189 | ||
|
|
b5e8f038c1 | ||
|
|
bd98ea5629 | ||
|
|
e0e45d5ed3 | ||
|
|
65b04687bc | ||
|
|
b3e9a2eec3 | ||
|
|
162bb4d80e | ||
|
|
e5e4cd0b58 | ||
|
|
316d1e6a63 | ||
|
|
60e006e8d7 | ||
|
|
dab39e78be | ||
|
|
6edcd2a463 | ||
|
|
bdfe666b8d | ||
|
|
8acc580c5b | ||
|
|
a3a4ddde2a | ||
|
|
db8470fbf9 | ||
|
|
a0b9fabb69 | ||
|
|
b9c916cb22 | ||
|
|
8a00476d38 | ||
|
|
69798b3e86 | ||
|
|
dca8d898cb | ||
|
|
738c9eaf45 | ||
|
|
f6bd779fe0 | ||
|
|
e7e196caba | ||
|
|
9bf45d65ff | ||
|
|
db9a94f335 | ||
|
|
660ebac628 | ||
|
|
21b7083160 | ||
|
|
1c0865ae15 | ||
|
|
63bda30595 | ||
|
|
c605ceefae | ||
|
|
0bda64efc4 | ||
|
|
452d666547 | ||
|
|
af441894d7 | ||
|
|
e8feed1d1b | ||
|
|
fed45f423e | ||
|
|
93ecbbd410 | ||
|
|
7bdcc61651 | ||
|
|
22308f21f6 | ||
|
|
d366f319e9 | ||
|
|
104439c239 | ||
|
|
3cb1e3e3f3 | ||
|
|
740bcaaf7d | ||
|
|
5877f7817f | ||
|
|
7947e2e77d | ||
|
|
fc5c7d5ca4 | ||
|
|
9d7efa34a6 | ||
|
|
ca4d4833ff | ||
|
|
3358ea5058 | ||
|
|
d4afcced87 | ||
|
|
33f517620b | ||
|
|
441735d39d | ||
|
|
0194748e3b | ||
|
|
d6c3cd2e24 | ||
|
|
9b39d0e5ec | ||
|
|
dec8b15278 | ||
|
|
a2e749c72d | ||
|
|
45f98e8154 | ||
|
|
efd13b6f60 | ||
|
|
06f9450485 | ||
|
|
d204536215 | ||
|
|
3047c2ba59 | ||
|
|
65c1ed86e1 | ||
|
|
4ae6d02d55 | ||
|
|
4b772c8db3 | ||
|
|
776c8be5cb | ||
|
|
caba84ea08 | ||
|
|
8515676f4d | ||
|
|
d07ac42452 | ||
|
|
0248aea542 | ||
|
|
4872fe5ba1 | ||
|
|
802b7a414e | ||
|
|
13ce4b7e88 | ||
|
|
1a6edd255a | ||
|
|
3a4af64a64 | ||
|
|
67089c2135 |
420 changed files with 112287 additions and 3565 deletions
|
|
@ -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
63
.gitattributes
vendored
|
|
@ -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
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
34
.github/renovate.json
vendored
|
|
@ -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\\./"],
|
||||
|
|
|
|||
29
.github/workflows/architecture-quality.yml
vendored
29
.github/workflows/architecture-quality.yml
vendored
|
|
@ -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
|
||||
111
.github/workflows/ci.yml
vendored
111
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/dependency-review.yml
vendored
8
.github/workflows/dependency-review.yml
vendored
|
|
@ -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
45
.github/workflows/deploy-tag.yaml
vendored
Normal 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
405
.github/workflows/deploy.yaml
vendored
Normal 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
|
||||
864
.github/workflows/deploy.yml
vendored
864
.github/workflows/deploy.yml
vendored
|
|
@ -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
91
.github/workflows/release.yaml
vendored
Normal 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
|
||||
6
.platform/nginx/conf.d/01_upload_body_size.conf
Normal file
6
.platform/nginx/conf.d/01_upload_body_size.conf
Normal 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;
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
60
Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs
Normal file
60
Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
56
Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs
Normal file
56
Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs
Normal 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
249
Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs
Normal file
249
Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>>())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
214
Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs
Normal file
214
Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
607
Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs
Normal file
607
Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
452
Api.SeaHavenIndustries.Tests/NotificationActivityFeedTests.cs
Normal file
452
Api.SeaHavenIndustries.Tests/NotificationActivityFeedTests.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
590
Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs
Normal file
590
Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
402
Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs
Normal file
402
Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
235
Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs
Normal file
235
Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
105
Api.SeaHavenIndustries.Tests/SendMessageTests.cs
Normal file
105
Api.SeaHavenIndustries.Tests/SendMessageTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
185
Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs
Normal file
185
Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
396
Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs
Normal file
396
Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
138
Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs
Normal file
138
Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
503
Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs
Normal file
503
Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
171
Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs
Normal file
171
Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs
Normal 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"))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
};
|
||||
}
|
||||
460
Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs
Normal file
460
Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs
Normal 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"));
|
||||
}
|
||||
|
|
@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
215
Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs
Normal file
215
Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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 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()),
|
||||
accountResolver: new WorkOrderAccountResolver(
|
||||
new AccountDataService(context),
|
||||
new LocationDataService(context)),
|
||||
detailData: new WorkOrderDetailDataService(context));
|
||||
|
||||
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 admin = UserWithRoles("Admin");
|
||||
((ClaimsIdentity)admin.Identity!).AddClaim(
|
||||
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll));
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(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) { }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
@ -215,7 +322,7 @@ public class UpliftControllerTests
|
|||
public async Task DownloadEvidence_NotFound_Returns404()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 99, It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 99, It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.NotFound());
|
||||
var controller = NewController(service);
|
||||
|
||||
|
|
@ -228,7 +335,7 @@ public class UpliftControllerTests
|
|||
public async Task DownloadEvidence_Locked_Returns423()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.Locked());
|
||||
var controller = NewController(service);
|
||||
|
||||
|
|
@ -244,7 +351,7 @@ public class UpliftControllerTests
|
|||
public async Task DownloadEvidence_Available_ReturnsFile()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.Ok(new MemoryStream(new byte[] { 1, 2, 3 }), "application/pdf", "invoice.pdf"));
|
||||
var controller = NewController(service);
|
||||
|
||||
|
|
@ -257,7 +364,7 @@ public class UpliftControllerTests
|
|||
public async Task DownloadEvidence_Forbidden_ReturnsSanitized403()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||
.ThrowsAsync(new UpliftForbiddenException("SECRET-role-policy"));
|
||||
var controller = NewController(service);
|
||||
|
||||
|
|
|
|||
1045
Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs
Normal file
1045
Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs
Normal file
File diff suppressed because it is too large
Load diff
278
Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs
Normal file
278
Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using SeaHaven.DataServices.Implementation;
|
|||
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;
|
||||
|
|
@ -128,18 +129,45 @@ public sealed class UpliftWorkflowTests
|
|||
Version = 1
|
||||
};
|
||||
|
||||
private static ClaimsPrincipal WorkOrderViewer(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, "user-42"),
|
||||
new(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
|
||||
};
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal OtherAccountUser()
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, "user-99"),
|
||||
new(SeaHavenClaimTypes.AccountId, "99"),
|
||||
new(ClaimTypes.Role, "Admin"),
|
||||
};
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
|
||||
}
|
||||
|
||||
private static UpliftService NewUpliftService(
|
||||
ApplicationDbContext context, IVendorDocumentStoragePort storage) =>
|
||||
new(new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
storage,
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(NewOptions()));
|
||||
Microsoft.Extensions.Options.Options.Create(NewOptions()),
|
||||
accountResolver: new WorkOrderAccountResolver(
|
||||
new AccountDataService(context),
|
||||
new LocationDataService(context)),
|
||||
detailData: new WorkOrderDetailDataService(context));
|
||||
|
||||
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 +178,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 +242,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 +451,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 +528,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 +837,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);
|
||||
|
||||
|
|
@ -748,7 +937,7 @@ public sealed class UpliftWorkflowTests
|
|||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, storage);
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||
result.ContentType.Should().Be("application/pdf");
|
||||
|
|
@ -756,30 +945,139 @@ public sealed class UpliftWorkflowTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ThrowsForbidden_WhenUserLacksRequiredTier()
|
||||
public async Task GetEvidenceForDownload_ReturnsOk_WhenTierZeroAndCallerCanSeeWorkOrder()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
var storage = new FakeDocumentStorage();
|
||||
await storage.SaveAsync(vendor.Id, dispatch.Id, "evidence.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 4000m,
|
||||
RequestedNTE = 200m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 2,
|
||||
Status = UpliftStatus.NoApprovalRequired,
|
||||
RequiredTier = 0,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, storage);
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Dispatcher"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||
result.FileName.Should().Be("invoice.pdf");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ThrowsForbidden_WhenWorkOrderIsOutsideAccount()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
workOrder.AccountId = 1;
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 200m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.NoApprovalRequired,
|
||||
RequiredTier = 0,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var act = () => service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
var act = () => service.GetEvidenceForDownloadAsync(OtherAccountUser(), 1, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UpliftForbiddenException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ReturnsOk_ForASecondLinkedDocument()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
var storage = new FakeDocumentStorage();
|
||||
await storage.SaveAsync(vendor.Id, dispatch.Id, "evidence.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None);
|
||||
await storage.SaveAsync(vendor.Id, dispatch.Id, "photo.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None);
|
||||
var quote = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id);
|
||||
quote.Id = 1;
|
||||
var photo = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id);
|
||||
photo.Id = 2;
|
||||
photo.OriginalFileName = "photo.jpg";
|
||||
photo.StoredFileName = "photo.bin";
|
||||
photo.ContentType = "image/jpeg";
|
||||
context.VendorCompletionDocuments.AddRange(quote, photo);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 200m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.NoApprovalRequired,
|
||||
RequiredTier = 0,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequestDocuments.Add(new DispatchUpliftRequestDocument
|
||||
{
|
||||
UpliftRequestId = 1,
|
||||
DocumentId = 2
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, storage);
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(
|
||||
WorkOrderViewer("Dispatcher"),
|
||||
1,
|
||||
CancellationToken.None,
|
||||
documentId: 2);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||
result.FileName.Should().Be("photo.jpg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ReturnsNotFound_WhenDocumentIsNotLinked()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
var linked = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id);
|
||||
linked.Id = 1;
|
||||
var stranger = EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id);
|
||||
stranger.Id = 2;
|
||||
stranger.StoredFileName = "other.bin";
|
||||
context.VendorCompletionDocuments.AddRange(linked, stranger);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 200m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(
|
||||
WorkOrderViewer("Approver"),
|
||||
1,
|
||||
CancellationToken.None,
|
||||
documentId: 2);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ReturnsNotFound_WhenNoLinkedEvidence()
|
||||
{
|
||||
|
|
@ -799,7 +1097,7 @@ public sealed class UpliftWorkflowTests
|
|||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||
}
|
||||
|
|
@ -824,7 +1122,7 @@ public sealed class UpliftWorkflowTests
|
|||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Locked);
|
||||
}
|
||||
|
|
@ -849,7 +1147,7 @@ public sealed class UpliftWorkflowTests
|
|||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||
}
|
||||
|
|
@ -880,8 +1178,73 @@ public sealed class UpliftWorkflowTests
|
|||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
var result = await service.GetEvidenceForDownloadAsync(WorkOrderViewer("Approver"), 1, CancellationToken.None);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ public class WorkOrderControllerSearchTests
|
|||
Mock.Of<IWorkOrderBoardUpdateService>(),
|
||||
Mock.Of<IWorkOrderBoardCreateService>(),
|
||||
Mock.Of<IWorkOrderBoardCancelService>(),
|
||||
Mock.Of<IWorkOrderPocService>(),
|
||||
advancedSearch);
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
106
Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs
Normal file
106
Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs
Normal 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!");
|
||||
}
|
||||
}
|
||||
}
|
||||
217
Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs
Normal file
217
Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs
Normal 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) }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs
Normal file
110
Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
96
Api.SeaHavenIndustries/Controllers/TeamMemberController.cs
Normal file
96
Api.SeaHavenIndustries/Controllers/TeamMemberController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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." })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
@ -138,11 +167,18 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
// document id is accepted from the client. 404 covers missing request/evidence and
|
||||
// any non-UpliftEvidence document; 423 covers a scan that has not Passed.
|
||||
[HttpGet("{id:int}/evidence")]
|
||||
public async Task<IActionResult> DownloadEvidence(int id, CancellationToken cancellationToken = default)
|
||||
public async Task<IActionResult> DownloadEvidence(
|
||||
int id,
|
||||
[FromQuery] int? documentId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _upliftService.GetEvidenceForDownloadAsync(User, id, cancellationToken);
|
||||
var result = await _upliftService.GetEvidenceForDownloadAsync(
|
||||
User,
|
||||
id,
|
||||
cancellationToken,
|
||||
documentId);
|
||||
return result.Outcome switch
|
||||
{
|
||||
VendorDocumentDownloadOutcome.Ok => File(result.Content!, result.ContentType!, result.FileName!),
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
24
Api.SeaHavenIndustries/DTOs/LoginPayload.cs
Normal file
24
Api.SeaHavenIndustries/DTOs/LoginPayload.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -43,6 +43,17 @@ namespace Data.SeaHavenIndustries
|
|||
.Property(u => u.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
||||
builder.Entity<DispatchUpliftRequestDocument>(entity =>
|
||||
{
|
||||
entity.HasKey(link => new { link.UpliftRequestId, link.DocumentId });
|
||||
entity.HasOne(link => link.UpliftRequest)
|
||||
.WithMany()
|
||||
.HasForeignKey(link => link.UpliftRequestId);
|
||||
entity.HasOne(link => link.Document)
|
||||
.WithMany()
|
||||
.HasForeignKey(link => link.DocumentId);
|
||||
});
|
||||
|
||||
builder.Entity<WorkOrderFieldLock>()
|
||||
.HasIndex(l => new { l.WorkOrderId, l.FieldName })
|
||||
.IsUnique();
|
||||
|
|
@ -54,6 +65,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 +108,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 +204,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 +246,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 +387,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 +396,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; }
|
||||
|
|
@ -239,8 +408,11 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<DispatchChecklistItem> DispatchChecklistItems { get; set; }
|
||||
public DbSet<DispatchSignoff> DispatchSignoffs { get; set; }
|
||||
public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; }
|
||||
public DbSet<DispatchUpliftRequestDocument> DispatchUpliftRequestDocuments { 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 +431,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 +513,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
|
||||
{
|
||||
|
|
|
|||
47
Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs
Normal file
47
Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs
Normal file
18
Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs
Normal 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
Loading…
Add table
Reference in a new issue