diff --git a/CLAUDE.md b/CLAUDE.md
index 00b56ab0d..5083e7f13 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,10 +45,12 @@ When I ask a question or make an observation, respond with an answer - do NOT ju
## Testing & Git
+- **No VMACS production calls**: Unit tests, local environments, and test environments must NEVER send requests to the VMACS production server. All external HTTP requests (e.g., VMACS, Instinct, IAM API) must be mocked using in-memory databases and mock HTTP clients/factories to prevent production spam.
- **UI**: Test UI changes with Playwright MCP (modals, forms, keyboard nav)
- **API**: Use Playwright MCP to visit endpoints, APIs require browser auth, `curl` fails
- **Branch & merge flow**: Branch off `main`, named `feature/`|`fix/`|etc. plus the JIRA ticket if applicable (e.g. `feature/VPR-104-clinical-scheduler`). After code review, merge into `Development` and push, which deploys to TEST. After the PR is approved on TEST, merge into `main`. Every change goes through `Development` first.
- **Never branch off `Development`**: it is a merge/deploy target, never a base. A branch being "behind `Development`" is expected and not a concern (you never sync or rebase from it). Its history is messy by design and never rewritten.
- **Squash during review**: If a branch is still unmerged and worked by a single developer, squash code-review fixes into the relevant existing commit for cleaner history rather than stacking "address review" commits.
- **Plan/smoketest notes**: `PLAN-*.md` and `SMOKETEST-*.md` at the repo root are local working notes, gitignored by design.
+- **Code-review self-check**: If `PLAN-code-review-lessons.md` exists at the repo root (a personal, gitignored note — may not exist on every machine or for every developer; skip silently if absent), read it and run its pre-flight checklist before saying a change is ready to build, test, or commit.
- **Commit messages**: Conventional Commits `type(scope): subject` (`feat`|`fix`|`refactor`|`docs`|`test`|`chore`; prefer `feat` for new behavior), ticket ID from branch as prefix (e.g. `VPR-104 fix(a11y): ...`). Subject: imperative, max 72 chars, no trailing period, intent not implementation. Body only when the subject is insufficient: `-` bullets that each earn their place (skip plumbing/helpers/test scaffolding), wrapped at 72.
diff --git a/Viper.sln.DotSettings b/Viper.sln.DotSettings
index 1b48c85a1..ec92ed564 100644
--- a/Viper.sln.DotSettings
+++ b/Viper.sln.DotSettings
@@ -2,4 +2,6 @@
<Profile name="OptimizeUsings"><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings></Profile>
<Profile name="ShortenReferences"><CSShortenReferences>True</CSShortenReferences></Profile>
<Profile name="RemoveRedundancies"><CSRemoveCodeRedundancies>True</CSRemoveCodeRedundancies><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSRemoveRedundantArgumentDefaultValues>True</CSRemoveRedundantArgumentDefaultValues><CSRemoveRedundantInitializers>True</CSRemoveRedundantInitializers></Profile>
+ DO_NOT_SHOW
+ DO_NOT_SHOW
diff --git a/VueApp/src/Effort/pages/SchoolSummary.vue b/VueApp/src/Effort/pages/SchoolSummary.vue
index 0bffded93..7581eed48 100644
--- a/VueApp/src/Effort/pages/SchoolSummary.vue
+++ b/VueApp/src/Effort/pages/SchoolSummary.vue
@@ -84,6 +84,7 @@
|
diff --git a/VueApp/src/styles/colors.css b/VueApp/src/styles/colors.css
index 644bce9d1..ef3be88e6 100644
--- a/VueApp/src/styles/colors.css
+++ b/VueApp/src/styles/colors.css
@@ -112,3 +112,22 @@
border-left: 0.25rem solid var(--q-negative);
color: var(--q-negative);
}
+
+/* Warning-state surface — mirrors StatusBanner type="warning" treatment
+ (own padding/layout, not layered onto Quasar's .q-banner) for Razor pages
+ that have no Vue mount, e.g. UserInfo.cshtml's unavailable-sections banner.
+ Mirrored into web/wwwroot/css/site.css so Razor and Vue render it
+ identically - see that copy for why it can't just reuse .q-banner. */
+.warning-surface {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ border-radius: 4px;
+ background-color: color-mix(in srgb, var(--q-warning) 15%, white);
+ border-left: 0.25rem solid var(--q-warning);
+ color: #5d4600;
+}
+.warning-surface .material-icons {
+ flex: none;
+}
diff --git a/scripts/build-dotnet.js b/scripts/build-dotnet.js
index 4a0100323..206a84162 100644
--- a/scripts/build-dotnet.js
+++ b/scripts/build-dotnet.js
@@ -25,6 +25,15 @@ const artifactsPath = ".artifacts-precommit"
// that, which would surface as a false "build failed" instead of the real result.
const MAX_BUILD_OUTPUT = 10_485_760
+// .artifacts-precommit is a separate, isolated output path (deliberately kept apart from
+// the normal bin/obj folders to avoid file-lock conflicts with a running dev server), so a
+// rebuild here doesn't get the benefit of warm incremental state the way a plain `dotnet
+// build` does. A cold build of this solution - more so right after a merge, and with
+// SonarAnalyzer's security/vulnerability rule categories enabled - can take a while. 120s
+// was too tight and got mistaken for a real compile failure when execFileSync killed the
+// process mid-build; give it more headroom.
+const BUILD_TIMEOUT_MS = 600_000
+
// Check if either project needs rebuild
const webNeedsBuild = needsBuild("web", "Viper.csproj")
const testNeedsBuild = needsBuild("test", "Viper.test.csproj")
@@ -55,7 +64,7 @@ try {
["build", "test/", "--artifacts-path", artifactsPath, "--verbosity", "quiet", "--nologo"],
{
encoding: "utf8",
- timeout: 120_000,
+ timeout: BUILD_TIMEOUT_MS,
maxBuffer: MAX_BUILD_OUTPUT,
stdio: ["inherit", "pipe", "pipe"],
env: { ...env, DOTNET_USE_COMPILER_SERVER: "1" },
@@ -68,6 +77,25 @@ try {
logger.success("Build complete")
} catch (error) {
const output = (error.stdout || "") + (error.stderr || "")
+
+ if (error.signal) {
+ // execFileSync kills the child and sets `signal` (not a normal exit code) when the
+ // timeout fires - that's an environment/timing issue, not a deterministic function
+ // of the code, so it must NOT be cached as a build failure. Caching it would make
+ // every future run replay this same "failure" (via the cached-output branch above)
+ // for as long as the file hashes stay unchanged, exactly like a real compile error,
+ // even though the code may be fine and just needs another (or longer) attempt.
+ logger.error(
+ `Build timed out after ${BUILD_TIMEOUT_MS / 1000}s (signal ${error.signal}) - ` +
+ "not a compile error, so nothing was cached. Try again; if it keeps timing out, " +
+ "BUILD_TIMEOUT_MS in this script may need to go up further.",
+ )
+ if (output) {
+ console.error(filterBuildErrors(output))
+ }
+ process.exit(1)
+ }
+
// Cache failure - no point rebuilding if code hasn't changed
markAsBuilt("web", "Viper.csproj", output, false)
markAsBuilt("test", "Viper.test.csproj", output, false)
diff --git a/scripts/lint-any.js b/scripts/lint-any.js
index 7412ab8fd..82a65add5 100644
--- a/scripts/lint-any.js
+++ b/scripts/lint-any.js
@@ -4,9 +4,18 @@ import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { spawn, spawnSync } from "node:child_process"
+import crossSpawn from "cross-spawn"
const { env } = process
+// On Windows, npx resolves to npx.cmd. Node's own spawnSync/spawn refuse to
+// run .cmd/.bat files directly without shell: true (CVE-2024-27980 fix), but
+// shell: true re-tokenizes the whole command line through cmd.exe, breaking
+// file paths with spaces or &. cross-spawn resolves and invokes npx.cmd
+// correctly on Windows (and plain npx elsewhere) without either problem, so
+// oxfmt is spawned through it rather than raw child_process.
+const NPX_COMMAND = "npx"
+
// Windows command line limit is ~8191 chars. Use a conservative threshold
// to account for the node executable path and script path overhead.
const MAX_ARG_LENGTH = 7000
@@ -214,7 +223,7 @@ function categorizeFiles(files) {
* @returns {{passed: boolean, failed: string[]}} - Result with pass status and failed files
*/
function runOxfmtCheckBatch(files) {
- const result = spawnSync("npx", ["oxfmt", "--check", ...files], {
+ const result = crossSpawn.sync(NPX_COMMAND, ["oxfmt", "--check", ...files], {
stdio: "pipe",
cwd: projectRoot,
encoding: "utf8",
@@ -266,7 +275,7 @@ function runOxfmtCheck(files, fix) {
}
if (fix) {
- const result = spawnSync("npx", ["oxfmt", "--write", ...batch], {
+ const result = crossSpawn.sync(NPX_COMMAND, ["oxfmt", "--write", ...batch], {
stdio: "inherit",
cwd: projectRoot,
})
diff --git a/test/Areas/Directory/VMACSServiceTest.cs b/test/Areas/Directory/VMACSServiceTest.cs
index 7039de4f0..45440383b 100644
--- a/test/Areas/Directory/VMACSServiceTest.cs
+++ b/test/Areas/Directory/VMACSServiceTest.cs
@@ -31,7 +31,6 @@ public void BuildSearchPath_IncludesAuthToken()
[Theory]
[InlineData("https://vmacs-qa.example.edu")]
- [InlineData("http://vmacs.example.edu/")]
public void IsValidBaseUrl_True_ForAbsoluteHttpUrls(string baseUrl)
{
Assert.True(VMACSService.IsValidBaseUrl(baseUrl));
@@ -44,7 +43,7 @@ public void IsValidBaseUrl_True_ForAbsoluteHttpUrls(string baseUrl)
[InlineData("not-a-url")]
[InlineData("/relative/path")]
[InlineData("vmacs-qa.example.edu")]
- [InlineData("ftp://vmacs.example.edu")]
+ [InlineData("ftp://vmacs-qa.example.edu")]
[InlineData("file:///etc/passwd")]
public void IsValidBaseUrl_False_ForMissingOrNonHttpUrls(string? baseUrl)
{
diff --git a/test/CMS/CMSUserPhotoControllerTests.cs b/test/CMS/CMSUserPhotoControllerTests.cs
index 8b93f6da4..891d4fc77 100644
--- a/test/CMS/CMSUserPhotoControllerTests.cs
+++ b/test/CMS/CMSUserPhotoControllerTests.cs
@@ -9,9 +9,11 @@ namespace Viper.test.CMS;
///
/// Controller wiring tests for CMSUserPhotoController: each by-id-type endpoint forwards only
-/// its identifier (and the altPhoto flag) to ICmsUserPhotoService and returns the bytes as an
-/// image/jpeg file response with a private cache header; conditional requests (If-Modified-Since)
-/// short-circuit to 304 per FIX 4.
+/// its identifier to ICmsUserPhotoService, routing to GetUserPhotoAsync when altPhoto is false
+/// and GetAlternatePhotoAsync when altPhoto is true, and returns the bytes as an image/jpeg file
+/// response with a private cache header; conditional requests (If-Modified-Since) short-circuit
+/// to 304 per FIX 4. A null result from GetAlternatePhotoAsync (no alternate photo for this
+/// person) returns 404 rather than falling back to another image.
///
public sealed class CMSUserPhotoControllerTests
{
@@ -39,7 +41,7 @@ public CMSUserPhotoControllerTests()
public async Task GetByMailId_ForwardsMailIdOnly()
{
var bytes = new byte[] { 1, 2, 3 };
- _photoService.GetUserPhotoAsync("mail@example.com", null, null, null, false, Arg.Any())
+ _photoService.GetUserPhotoAsync("mail@example.com", null, null, null, Arg.Any())
.Returns(Result(bytes));
var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
@@ -47,51 +49,65 @@ public async Task GetByMailId_ForwardsMailIdOnly()
var file = Assert.IsType(result);
Assert.Equal("image/jpeg", file.ContentType);
Assert.Equal(bytes, file.FileContents);
- await _photoService.Received(1).GetUserPhotoAsync("mail@example.com", null, null, null, false,
+ await _photoService.Received(1).GetUserPhotoAsync("mail@example.com", null, null, null,
Arg.Any());
}
[Fact]
- public async Task GetByLoginId_ForwardsLoginIdAndAltPhotoFlag()
+ public async Task GetByLoginId_AltPhoto_CallsGetAlternatePhotoWithLoginId()
{
- _photoService.GetUserPhotoAsync(null, "loginX", null, null, true, Arg.Any())
+ _photoService.GetAlternatePhotoAsync(null, "loginX", null, null, Arg.Any())
.Returns(Result(new byte[] { 9 }));
var result = await _controller.GetByLoginId("loginX", altPhoto: true, TestContext.Current.CancellationToken);
Assert.IsType(result);
- await _photoService.Received(1).GetUserPhotoAsync(null, "loginX", null, null, true, Arg.Any());
+ await _photoService.Received(1).GetAlternatePhotoAsync(null, "loginX", null, null, Arg.Any());
+ await _photoService.DidNotReceive().GetUserPhotoAsync(Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any());
}
[Fact]
public async Task GetByIamId_ForwardsIamIdOnly()
{
- _photoService.GetUserPhotoAsync(null, null, "1000123", null, false, Arg.Any())
+ _photoService.GetUserPhotoAsync(null, null, "1000123", null, Arg.Any())
.Returns(Result(new byte[] { 7 }));
var result = await _controller.GetByIamId("1000123", ct: TestContext.Current.CancellationToken);
Assert.IsType(result);
- await _photoService.Received(1).GetUserPhotoAsync(null, null, "1000123", null, false, Arg.Any());
+ await _photoService.Received(1).GetUserPhotoAsync(null, null, "1000123", null, Arg.Any());
}
[Fact]
public async Task GetByMothraId_ForwardsMothraIdOnly()
{
- _photoService.GetUserPhotoAsync(null, null, null, "m-9001", false, Arg.Any())
+ _photoService.GetUserPhotoAsync(null, null, null, "m-9001", Arg.Any())
.Returns(Result(new byte[] { 5 }));
var result = await _controller.GetByMothraId("m-9001", ct: TestContext.Current.CancellationToken);
Assert.IsType(result);
- await _photoService.Received(1).GetUserPhotoAsync(null, null, null, "m-9001", false, Arg.Any());
+ await _photoService.Received(1).GetUserPhotoAsync(null, null, null, "m-9001", Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetByMailId_AltPhotoMissing_ReturnsNotFound()
+ {
+ _photoService.GetAlternatePhotoAsync("mail@example.com", null, null, null, Arg.Any())
+ .Returns((CmsUserPhotoResult?)null);
+
+ var result = await _controller.GetByMailId("mail@example.com", altPhoto: true,
+ ct: TestContext.Current.CancellationToken);
+
+ Assert.IsType(result);
}
[Fact]
public async Task ServePhoto_SetsPrivateCacheHeader()
{
_photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
- Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1 }));
+ Arg.Any()).Returns(Result(new byte[] { 1 }));
await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
@@ -104,7 +120,7 @@ public async Task ServePhoto_SetsPrivateCacheHeader()
public async Task ServePhoto_SetsLastModifiedHeader()
{
_photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
- Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1 }));
+ Arg.Any()).Returns(Result(new byte[] { 1 }));
await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
@@ -115,7 +131,7 @@ public async Task ServePhoto_SetsLastModifiedHeader()
public async Task ServePhoto_ReturnsNotModified_WhenIfModifiedSinceCoversLastModified()
{
_photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
- Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1, 2, 3 }));
+ Arg.Any()).Returns(Result(new byte[] { 1, 2, 3 }));
_controller.Request.GetTypedHeaders().IfModifiedSince = PhotoLastModified;
var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
@@ -128,7 +144,7 @@ public async Task ServePhoto_ReturnsNotModified_WhenIfModifiedSinceCoversLastMod
public async Task ServePhoto_ReturnsNotModified_WhenIfModifiedSinceIsAfterLastModified()
{
_photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
- Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1, 2, 3 }));
+ Arg.Any()).Returns(Result(new byte[] { 1, 2, 3 }));
_controller.Request.GetTypedHeaders().IfModifiedSince = PhotoLastModified.AddDays(1);
var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
@@ -142,7 +158,7 @@ public async Task ServePhoto_ReturnsFile_WhenIfModifiedSinceIsBeforeLastModified
{
var bytes = new byte[] { 1, 2, 3 };
_photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
- Arg.Any(), Arg.Any()).Returns(Result(bytes));
+ Arg.Any()).Returns(Result(bytes));
_controller.Request.GetTypedHeaders().IfModifiedSince = PhotoLastModified.AddDays(-1);
var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
@@ -156,7 +172,7 @@ public async Task ServePhoto_ReturnsFile_WhenNoIfModifiedSinceHeaderSent()
{
var bytes = new byte[] { 1, 2, 3 };
_photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
- Arg.Any(), Arg.Any()).Returns(Result(bytes));
+ Arg.Any()).Returns(Result(bytes));
var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken);
diff --git a/test/CMS/CmsUserPhotoServiceTests.cs b/test/CMS/CmsUserPhotoServiceTests.cs
index a352ab603..f0545d6f0 100644
--- a/test/CMS/CmsUserPhotoServiceTests.cs
+++ b/test/CMS/CmsUserPhotoServiceTests.cs
@@ -13,6 +13,11 @@ namespace Viper.test.CMS;
/// Tests for CmsUserPhotoService: AAUD id resolution (MailId, LoginId, IamId, MothraId),
/// alternate ProfilePhotos lookup with traversal protection, delegation to the Students photo
/// pipeline, and the Last-Modified timestamp used for conditional-caching (FIX 4).
+/// GetUserPhotoAsync (the primary/id-card photo) always returns a result, falling back to the
+/// default "no picture" image, which makes it the right choice for most places a photo is
+/// displayed. GetAlternatePhotoAsync has no such fallback: a missing/rejected alt photo returns
+/// null rather than the primary id-card photo, so a caller can tell "no alt photo" apart from a
+/// successful lookup instead of the two being indistinguishable.
///
public sealed class CmsUserPhotoServiceTests : IDisposable
{
@@ -82,7 +87,7 @@ private void SeedUser(string loginId = "jdoe", string mailId = "jdoe", string ia
[Fact]
public async Task GetUserPhoto_ByMailId_DelegatesToStudentPipeline()
{
- var photo = await _service.GetUserPhotoAsync("jdoe", null, null, null, preferAltPhoto: false,
+ var photo = await _service.GetUserPhotoAsync("jdoe", null, null, null,
TestContext.Current.CancellationToken);
Assert.Equal(IdCardPhotoBytes, photo.Bytes);
@@ -94,7 +99,7 @@ public async Task GetUserPhoto_ByLoginId_ResolvesMailIdThroughAaud()
{
SeedUser(loginId: "jdoe", mailId: "janedoe");
- await _service.GetUserPhotoAsync(null, "jdoe", null, null, preferAltPhoto: false,
+ await _service.GetUserPhotoAsync(null, "jdoe", null, null,
TestContext.Current.CancellationToken);
await _photoService.Received(1).GetStudentPhotoAsync("janedoe");
@@ -105,7 +110,7 @@ public async Task GetUserPhoto_ByMothraId_ResolvesMailIdThroughAaud()
{
SeedUser(mothraId: "m-9001", mailId: "janedoe");
- await _service.GetUserPhotoAsync(null, null, null, "m-9001", preferAltPhoto: false,
+ await _service.GetUserPhotoAsync(null, null, null, "m-9001",
TestContext.Current.CancellationToken);
await _photoService.Received(1).GetStudentPhotoAsync("janedoe");
@@ -114,7 +119,7 @@ await _service.GetUserPhotoAsync(null, null, null, "m-9001", preferAltPhoto: fal
[Fact]
public async Task GetUserPhoto_UnknownMothraId_FallsBackToDefaultPipeline()
{
- var photo = await _service.GetUserPhotoAsync(null, null, null, "no-such-mothra", preferAltPhoto: false,
+ var photo = await _service.GetUserPhotoAsync(null, null, null, "no-such-mothra",
TestContext.Current.CancellationToken);
Assert.Equal(IdCardPhotoBytes, photo.Bytes);
@@ -124,7 +129,7 @@ public async Task GetUserPhoto_UnknownMothraId_FallsBackToDefaultPipeline()
[Fact]
public async Task GetUserPhoto_UnknownLoginId_FallsBackToDefaultPipeline()
{
- var photo = await _service.GetUserPhotoAsync(null, "nosuchuser", null, null, preferAltPhoto: false,
+ var photo = await _service.GetUserPhotoAsync(null, "nosuchuser", null, null,
TestContext.Current.CancellationToken);
Assert.Equal(IdCardPhotoBytes, photo.Bytes);
@@ -132,83 +137,134 @@ public async Task GetUserPhoto_UnknownLoginId_FallsBackToDefaultPipeline()
}
[Fact]
- public async Task GetUserPhoto_PreferAltPhoto_ServesProfilePhoto()
+ public async Task GetUserPhoto_IdCardPhoto_LastModifiedIsStableAcrossCalls()
+ {
+ // The delegated Students pipeline exposes no per-file timestamp, so id-card/nopic
+ // responses use a stable per-process proxy; repeated calls must agree.
+ var first = await _service.GetUserPhotoAsync("jdoe", null, null, null,
+ TestContext.Current.CancellationToken);
+ var second = await _service.GetUserPhotoAsync("jdoe", null, null, null,
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(first.LastModified, second.LastModified);
+ Assert.Equal(0, first.LastModified.Millisecond);
+ }
+
+ [Fact]
+ public async Task GetAlternatePhoto_ServesProfilePhoto()
{
SeedUser(iamId: "1000999");
await File.WriteAllBytesAsync(Path.Join(_profilePhotoRoot, "1000999.jpg"), AltPhotoBytes,
TestContext.Current.CancellationToken);
- var photo = await _service.GetUserPhotoAsync(null, "jdoe", null, null, preferAltPhoto: true,
+ var photo = await _service.GetAlternatePhotoAsync(null, "jdoe", null, null,
TestContext.Current.CancellationToken);
+ Assert.NotNull(photo);
Assert.Equal(AltPhotoBytes, photo.Bytes);
await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any());
}
[Fact]
- public async Task GetUserPhoto_ByMailIdPreferAltPhoto_ResolvesIamIdAndServesProfilePhoto()
+ public async Task GetAlternatePhoto_ByMailId_ResolvesIamIdAndServesProfilePhoto()
{
SeedUser(mailId: "jdoe", iamId: "1000999");
await File.WriteAllBytesAsync(Path.Join(_profilePhotoRoot, "1000999.jpg"), AltPhotoBytes,
TestContext.Current.CancellationToken);
- var photo = await _service.GetUserPhotoAsync("jdoe", null, null, null, preferAltPhoto: true,
+ var photo = await _service.GetAlternatePhotoAsync("jdoe", null, null, null,
TestContext.Current.CancellationToken);
+ Assert.NotNull(photo);
Assert.Equal(AltPhotoBytes, photo.Bytes);
await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any());
}
[Fact]
- public async Task GetUserPhoto_PreferAltPhoto_MissingAltFallsBackToIdCard()
+ public async Task GetAlternatePhoto_Missing_ReturnsNull()
{
SeedUser(mailId: "janedoe", iamId: "1000999");
- var photo = await _service.GetUserPhotoAsync(null, "jdoe", null, null, preferAltPhoto: true,
+ var photo = await _service.GetAlternatePhotoAsync(null, "jdoe", null, null,
TestContext.Current.CancellationToken);
- Assert.Equal(IdCardPhotoBytes, photo.Bytes);
- await _photoService.Received(1).GetStudentPhotoAsync("janedoe");
+ // Unlike the primary id-card photo, the alternate photo has no placeholder fallback -
+ // a missing alt photo means "don't render this image", not "show the primary instead".
+ Assert.Null(photo);
+ await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any());
}
[Theory]
[InlineData("../1000999")]
[InlineData("..\\secrets")]
[InlineData("a/b")]
- public async Task GetUserPhoto_TraversalShapedIamId_IgnoresAltPhoto(string iamId)
+ public async Task GetAlternatePhoto_TraversalShapedIamId_ReturnsNull(string iamId)
{
- var photo = await _service.GetUserPhotoAsync(null, null, iamId, null, preferAltPhoto: true,
+ var photo = await _service.GetAlternatePhotoAsync(null, null, iamId, null,
TestContext.Current.CancellationToken);
- Assert.Equal(IdCardPhotoBytes, photo.Bytes);
+ Assert.Null(photo);
+ await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any());
}
[Fact]
- public async Task GetUserPhoto_AltPhoto_LastModifiedReflectsFileWriteTime_TruncatedToWholeSeconds()
+ public async Task GetAlternatePhoto_LastModifiedReflectsFileWriteTime_TruncatedToWholeSeconds()
{
SeedUser(iamId: "1000999");
var photoPath = Path.Join(_profilePhotoRoot, "1000999.jpg");
await File.WriteAllBytesAsync(photoPath, AltPhotoBytes, TestContext.Current.CancellationToken);
var fileWriteTime = File.GetLastWriteTimeUtc(photoPath);
- var photo = await _service.GetUserPhotoAsync(null, "jdoe", null, null, preferAltPhoto: true,
+ var photo = await _service.GetAlternatePhotoAsync(null, "jdoe", null, null,
TestContext.Current.CancellationToken);
+ Assert.NotNull(photo);
Assert.Equal(0, photo.LastModified.Millisecond);
Assert.True(Math.Abs((photo.LastModified.UtcDateTime - fileWriteTime).TotalSeconds) < 1.5);
}
[Fact]
- public async Task GetUserPhoto_IdCardPhoto_LastModifiedIsStableAcrossCalls()
+ public async Task HasAlternatePhoto_ExistingFile_ReturnsTrue()
{
- // The delegated Students pipeline exposes no per-file timestamp, so id-card/nopic
- // responses use a stable per-process proxy; repeated calls must agree.
- var first = await _service.GetUserPhotoAsync("jdoe", null, null, null, preferAltPhoto: false,
+ await File.WriteAllBytesAsync(Path.Join(_profilePhotoRoot, "1000999.jpg"), AltPhotoBytes,
TestContext.Current.CancellationToken);
- var second = await _service.GetUserPhotoAsync("jdoe", null, null, null, preferAltPhoto: false,
+
+ var hasPhoto = await _service.HasAlternatePhotoAsync("1000999",
TestContext.Current.CancellationToken);
- Assert.Equal(first.LastModified, second.LastModified);
- Assert.Equal(0, first.LastModified.Millisecond);
+ Assert.True(hasPhoto);
+ }
+
+ [Fact]
+ public async Task HasAlternatePhoto_MissingFile_ReturnsFalse()
+ {
+ var hasPhoto = await _service.HasAlternatePhotoAsync("no-such-iam",
+ TestContext.Current.CancellationToken);
+
+ Assert.False(hasPhoto);
+ }
+
+ [Fact]
+ public async Task HasAlternatePhoto_DoesNotReadFileBytes()
+ {
+ // Distinguishes this from GetAlternatePhotoAsync: it should answer from a plain
+ // existence check without going through the Students photo pipeline at all.
+ await File.WriteAllBytesAsync(Path.Join(_profilePhotoRoot, "1000999.jpg"), AltPhotoBytes,
+ TestContext.Current.CancellationToken);
+
+ await _service.HasAlternatePhotoAsync("1000999", TestContext.Current.CancellationToken);
+
+ await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any());
+ }
+
+ [Theory]
+ [InlineData("../1000999")]
+ [InlineData("..\\secrets")]
+ [InlineData("a/b")]
+ public async Task HasAlternatePhoto_TraversalShapedIamId_ReturnsFalse(string iamId)
+ {
+ var hasPhoto = await _service.HasAlternatePhotoAsync(iamId, TestContext.Current.CancellationToken);
+
+ Assert.False(hasPhoto);
}
}
diff --git a/test/Directory/DirectoryControllerTests.cs b/test/Directory/DirectoryControllerTests.cs
index e73855ab6..21da87b67 100644
--- a/test/Directory/DirectoryControllerTests.cs
+++ b/test/Directory/DirectoryControllerTests.cs
@@ -21,8 +21,8 @@ public sealed class DirectoryControllerTests : IDisposable
///
/// A trimmed AAUDContext mapping only AaudUser: the full AAUD warehouse model has
/// computed columns and views SQLite EnsureCreated cannot build (same pattern as
- /// StudentGroupServiceQueryTests). Current is a plain column here, so tests set it
- /// directly instead of via the production computed column.
+ /// StudentGroupServiceQueryTests). Current and Future are plain columns here, so
+ /// tests set them directly instead of via the production computed columns.
///
private sealed class TestAaudContext(DbContextOptions options) : AAUDContext(options)
{
@@ -59,7 +59,7 @@ public void Dispose()
}
[Fact]
- public async Task SearchCurrentAaudUsers_MatchesNameAndEveryIdentifierField()
+ public async Task SearchCurrentOrFutureAaudUsers_MatchesNameAndEveryIdentifierField()
{
SeedUser(1, lastName: "Delfigo", firstName: "Ann");
SeedUser(2, lastName: "Berry", firstName: "Bo", mailId: "fig@ucdavis.edu");
@@ -72,52 +72,69 @@ public async Task SearchCurrentAaudUsers_MatchesNameAndEveryIdentifierField()
SeedUser(9, lastName: "Ivy", firstName: "Ira");
await _context.SaveChangesAsync(TestContext.Current.CancellationToken);
- var results = await DirectoryController.SearchCurrentAaudUsers(_context, "fig");
+ var results = await DirectoryController.SearchCurrentOrFutureAaudUsers(_context, "fig");
// One match per field, none for user 9, ordered by last name
Assert.Equal([2, 3, 4, 1, 5, 6, 7, 8], results.Select(u => u.AaudUserId));
}
[Fact]
- public async Task SearchCurrentAaudUsers_MatchesTermSpanningFirstAndLastName()
+ public async Task SearchCurrentOrFutureAaudUsers_MatchesTermSpanningFirstAndLastName()
{
SeedUser(1, lastName: "Graham", firstName: "Anna");
SeedUser(2, lastName: "Graham", firstName: "Steve");
await _context.SaveChangesAsync(TestContext.Current.CancellationToken);
- var results = await DirectoryController.SearchCurrentAaudUsers(_context, "na Gra");
+ var results = await DirectoryController.SearchCurrentOrFutureAaudUsers(_context, "na Gra");
Assert.Equal([1], results.Select(u => u.AaudUserId));
}
[Fact]
- public async Task SearchCurrentAaudUsers_ExcludesUsersNoLongerCurrent()
+ public async Task SearchCurrentOrFutureAaudUsers_ExcludesUsersNeitherCurrentNorFuture()
{
SeedUser(1, lastName: "Figworth", firstName: "Cur");
SeedUser(2, lastName: "Figworth", firstName: "Old", current: 0);
await _context.SaveChangesAsync(TestContext.Current.CancellationToken);
- var results = await DirectoryController.SearchCurrentAaudUsers(_context, "Figworth");
+ var results = await DirectoryController.SearchCurrentOrFutureAaudUsers(_context, "Figworth");
Assert.Equal([1], results.Select(u => u.AaudUserId));
}
[Fact]
- public async Task SearchCurrentAaudUsers_OrdersByLastNameThenFirstName()
+ public async Task SearchCurrentOrFutureAaudUsers_IncludesFutureOnlyUsers()
+ {
+ // An admitted/registered student for an upcoming term (e.g. before Fall quarter
+ // starts) is Future but not yet Current - AAUD only flips Current on once the
+ // term is underway. The legacy ColdFusion directory and campus LDAP already show
+ // these people, so this search needs to as well.
+ SeedUser(1, lastName: "Figworth", firstName: "Incoming", current: 0, future: 1);
+ SeedUser(2, lastName: "Figworth", firstName: "Gone", current: 0, future: 0);
+ await _context.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ var results = await DirectoryController.SearchCurrentOrFutureAaudUsers(_context, "Figworth");
+
+ Assert.Equal([1], results.Select(u => u.AaudUserId));
+ }
+
+ [Fact]
+ public async Task SearchCurrentOrFutureAaudUsers_OrdersByLastNameThenFirstName()
{
SeedUser(1, lastName: "Fig", firstName: "Zoe");
SeedUser(2, lastName: "Fig", firstName: "Al");
SeedUser(3, lastName: "Elm", firstName: "Bea", mailId: "Fig@ucdavis.edu");
await _context.SaveChangesAsync(TestContext.Current.CancellationToken);
- var results = await DirectoryController.SearchCurrentAaudUsers(_context, "Fig");
+ var results = await DirectoryController.SearchCurrentOrFutureAaudUsers(_context, "Fig");
Assert.Equal([3, 2, 1], results.Select(u => u.AaudUserId));
}
private void SeedUser(int id, string lastName, string firstName, string? mailId = null,
string? loginId = null, string? spridenId = null, string? pidm = null,
- string? mothraId = null, string? employeeId = null, string? iamId = null, int current = 1)
+ string? mothraId = null, string? employeeId = null, string? iamId = null, int current = 1,
+ int future = 0)
{
_context.AaudUsers.Add(new AaudUser
{
@@ -135,7 +152,8 @@ private void SeedUser(int id, string lastName, string firstName, string? mailId
DisplayLastName = lastName,
DisplayFirstName = firstName,
DisplayFullName = firstName + " " + lastName,
- Current = current
+ Current = current,
+ Future = future
});
}
}
diff --git a/test/Services/UserInfoServiceTests.cs b/test/Services/UserInfoServiceTests.cs
new file mode 100644
index 000000000..10aa0e743
--- /dev/null
+++ b/test/Services/UserInfoServiceTests.cs
@@ -0,0 +1,225 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using System.Net;
+using System.Text;
+using Viper.Areas.CMS.Services;
+using Viper.Areas.Directory.Models;
+using Viper.Areas.Directory.Services;
+using Viper.Classes.SQLContext;
+using Viper.Models.AAUD;
+using Viper.Models.Courses;
+
+namespace Viper.test.Services
+{
+ [Collection("HttpHelper static state")]
+ public class UserInfoServiceTests
+ {
+ private static readonly UserInfoViewPermissions AllPermissions = new()
+ {
+ IsOwnPage = true,
+ CanViewDirectoryDetail = true,
+ CanViewStudentID = true,
+ CanViewIAM = true,
+ CanViewRoles = true,
+ CanViewUCPath = true,
+ CanViewUCPathDetail = true,
+ CanViewIDCards = true,
+ CanViewKeys = true,
+ CanViewLoans = true,
+ CanViewInstinct = true,
+ CanViewADGroups = true
+ };
+
+ private readonly ITestOutputHelper _output;
+
+ public UserInfoServiceTests(ITestOutputHelper output)
+ {
+ _output = output;
+ }
+
+ private static DbContextOptions CreateInMemoryOptions() where TContext : DbContext
+ {
+ return new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
+ .Options;
+ }
+
+ [Fact]
+ public async Task TestGetUserInfo_WithSyntheticData()
+ {
+ _output.WriteLine("[DEBUG] Starting TestGetUserInfo with fully synthetic data...");
+
+ // 1. Arrange Config & Cache
+ var configData = new Dictionary
+ {
+ { "Instinct:ApiUrl", "https://synthetic.instinctvet.com/" },
+ { "Credentials:InstinctApi", "synthetic-password" }
+ };
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(configData)
+ .Build();
+
+ var memoryCache = new MemoryCache(new MemoryCacheOptions());
+
+ // 2. Mock HttpClientFactory
+ var mockHandler = new MockHttpMessageHandler(request =>
+ {
+ var uri = request.RequestUri?.ToString() ?? "";
+ if (uri.Contains("auth/token"))
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"access_token\": \"synthetic-token\", \"expires_in\": 86400}", Encoding.UTF8, "application/json")
+ };
+ }
+ if (uri.Contains("query="))
+ {
+ var searchJson = @"
+ {
+ ""data"": {
+ ""searchUsers"": [
+ {
+ ""id"": ""inst-synthetic-id"",
+ ""instinctId"": ""inst-synthetic-id"",
+ ""status"": ""Active"",
+ ""isActive"": true,
+ ""username"": ""jsmith"",
+ ""nameFirst"": ""John"",
+ ""nameLast"": ""Smith"",
+ ""roles"": [
+ { ""label"": ""Staff"" }
+ ]
+ }
+ ]
+ }
+ }";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(searchJson, Encoding.UTF8, "application/json")
+ };
+ }
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+
+ var httpClientFactory = Substitute.For();
+ httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(mockHandler));
+
+ // 3. Setup DbContexts and Seed Synthetic Data
+ var aaudOptions = CreateInMemoryOptions();
+ var coursesOptions = CreateInMemoryOptions();
+
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ var syntheticUser = new AaudUser
+ {
+ IamId = "99999999",
+ MothraId = "88888888",
+ MailId = "jsmith",
+ LoginId = "jsmith",
+ FirstName = "John",
+ LastName = "Smith",
+ DisplayFirstName = "John",
+ DisplayLastName = "Smith",
+ DisplayFullName = "John Smith",
+ Current = 1,
+ EmployeeId = "emp-999",
+ EmployeePKey = "pkey-999",
+ ClientId = "1"
+ };
+ aaudSetup.AaudUsers.Add(syntheticUser);
+
+ aaudSetup.Employees.Add(new Employee
+ {
+ EmpPKey = "pkey-999",
+ EmpTermCode = "202610",
+ EmpPrimaryTitle = "Synthetic Analyst",
+ EmpSchoolDivision = "Synthetic Division",
+ EmpStatus = "A",
+ EmpHomeDept = "Synthetic Dept",
+ EmpClientid = "1",
+ EmpAltDeptCode = "",
+ EmpCbuc = ""
+ });
+
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using (var coursesSetup = new CoursesContext(coursesOptions))
+ {
+ coursesSetup.Terminfos.Add(new Terminfo
+ {
+ TermCode = "202610",
+ TermCurrentTermMulti = true,
+ TermAcademicYear = "",
+ TermDesc = "",
+ TermCollCode = "01",
+ TermStartDate = DateTime.Today,
+ TermEndDate = DateTime.Today,
+ TermCurrentTerm = true,
+ TermTermType = ""
+ });
+ await coursesSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(coursesOptions);
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+ using var sis = new SISContext(CreateInMemoryOptions());
+
+ var userInfoService = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, sis, config, httpClientFactory, memoryCache, Substitute.For>(), Substitute.For());
+
+ // 4. Act
+ // Temporary HttpHelper configuration inside the test context
+ var mockEnv = Substitute.For();
+ HttpHelper.Configure(memoryCache, config, mockEnv, null, null, null, null);
+
+ UserInfoResult? result;
+ try
+ {
+ result = await userInfoService.GetUserInfoAsync("99999999", "88888888", AllPermissions);
+ }
+ finally
+ {
+ HttpHelper.Configure(null, null, null!, null, null, null, null);
+ }
+
+ // 5. Assert
+ Assert.NotNull(result);
+ _output.WriteLine($"[DEBUG] Result IamId: {result.IamId}");
+ _output.WriteLine($"[DEBUG] Result DisplayName: {result.DisplayFullName}");
+ _output.WriteLine($"[DEBUG] Result InstinctId: {result.InstinctId}");
+ _output.WriteLine($"[DEBUG] Result InstinctInfo.ErrorMessage: {result.InstinctInfo?.ErrorMessage}");
+ _output.WriteLine($"[DEBUG] Result InstinctInfo.Valid: {result.InstinctInfo?.Valid}");
+
+ Assert.Equal("99999999", result.IamId);
+ Assert.Equal("88888888", result.MothraId);
+ Assert.Equal("John Smith", result.DisplayFullName);
+ Assert.True(result.IsEmployee);
+ Assert.Equal("Synthetic Analyst", result.EmployeePrimaryTitle);
+ Assert.Equal("inst-synthetic-id", result.InstinctId);
+ Assert.Equal("jsmith", result.InstinctUsername);
+ }
+
+ private class MockHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly Func _handler;
+
+ public MockHttpMessageHandler(Func handler)
+ {
+ _handler = handler;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(_handler(request));
+ }
+ }
+ }
+}
diff --git a/test/Services/UserInfoServiceUnitTests.cs b/test/Services/UserInfoServiceUnitTests.cs
new file mode 100644
index 000000000..f677fad1d
--- /dev/null
+++ b/test/Services/UserInfoServiceUnitTests.cs
@@ -0,0 +1,1335 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using MockQueryable.NSubstitute;
+using NSubstitute;
+using System.Net;
+using System.Text;
+using Viper.Areas.CMS.Services;
+using Viper.Areas.Directory.Models;
+using Viper.Areas.Directory.Services;
+using Viper.Classes.SQLContext;
+using Viper.Classes.Utilities;
+using Viper.Models.AAUD;
+using Viper.Models.EquipmentLoan;
+using Viper.Models.IDCards;
+using Viper.Models.Keys;
+using Viper.Models.RAPS;
+using Viper.Models.Courses;
+using Viper.Models.PPS;
+
+namespace Viper.test.Services
+{
+ [Collection("HttpHelper static state")]
+ public class UserInfoServiceUnitTests
+ {
+ private static readonly UserInfoViewPermissions AllPermissions = new()
+ {
+ IsOwnPage = true,
+ CanViewDirectoryDetail = true,
+ CanViewStudentID = true,
+ CanViewIAM = true,
+ CanViewRoles = true,
+ CanViewUCPath = true,
+ CanViewUCPathDetail = true,
+ CanViewIDCards = true,
+ CanViewKeys = true,
+ CanViewLoans = true,
+ CanViewInstinct = true,
+ CanViewADGroups = true
+ };
+
+ private readonly IMemoryCache _memoryCache;
+ private readonly IConfiguration _configuration;
+
+ public UserInfoServiceUnitTests()
+ {
+ _memoryCache = new MemoryCache(new MemoryCacheOptions());
+
+ var configData = new Dictionary
+ {
+ { "Instinct:ApiUrl", "https://uc-davis.api.instinctvet.com/" },
+ { "Credentials:InstinctApi", "dummy-password" }
+ };
+ _configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(configData)
+ .Build();
+ }
+
+ // Helper to construct DbContextOptions for InMemory providers
+ private static DbContextOptions CreateInMemoryOptions() where TContext : DbContext
+ {
+ return new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
+ .Options;
+ }
+
+ // Helper to construct a mocked IHttpClientFactory using custom handler
+ private static IHttpClientFactory CreateMockHttpClientFactory(Func handlerFunc)
+ {
+ var factory = Substitute.For();
+ factory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(new MockHttpMessageHandler(handlerFunc)));
+ return factory;
+ }
+
+ private AaudUser CreateTestUser(
+ string iamId,
+ string mothraId,
+ string loginId = "testuser",
+ string? employeeId = null,
+ string? employeePKey = null,
+ string? studentPKey = null,
+ string? firstName = "Jane",
+ string? lastName = "Doe",
+ string? middleName = null,
+ string? displayFullName = "Jane Doe",
+ string? pidm = null)
+ {
+ return new AaudUser
+ {
+ IamId = iamId,
+ MothraId = mothraId,
+ MailId = loginId,
+ LoginId = loginId,
+ EmployeeId = employeeId,
+ EmployeePKey = employeePKey,
+ StudentPKey = studentPKey,
+ Current = 1,
+ ClientId = "1",
+ FirstName = firstName ?? "Jane",
+ LastName = lastName ?? "Doe",
+ MiddleName = middleName,
+ DisplayFirstName = firstName ?? "Jane",
+ DisplayLastName = lastName ?? "Doe",
+ DisplayFullName = displayFullName ?? "Jane Doe",
+ Pidm = pidm
+ };
+ }
+
+ [Fact]
+ public async Task GetUserInfoAsync_UserNotFound_ReturnsNull()
+ {
+ // Arrange
+ using var aaud = new AAUDContext(CreateInMemoryOptions());
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ // Act
+ var result = await service.GetUserInfoAsync("non-existent-iam", "non-existent-mothra", AllPermissions);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task GetUserInfoAsync_UserFoundByIamId_MapsProperties()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-123", "mothra-123", employeeId: "emp-123", pidm: "pidm-123", displayFullName: "Test User"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-123", null, AllPermissions);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal("iam-123", result.IamId);
+ Assert.Equal("mothra-123", result.MothraId);
+ Assert.Equal("Test User", result.DisplayFullName);
+ Assert.True(result.CurrentAffiliate);
+ }
+
+ [Fact]
+ public async Task GetUserInfoAsync_CanViewDirectoryDetail_SetsHasAltPhotoFromPhotoService()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-altphoto", "mothra-altphoto"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var photoService = Substitute.For();
+ photoService.HasAlternatePhotoAsync("iam-altphoto", Arg.Any()).Returns(true);
+
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), photoService);
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-altphoto", null, AllPermissions);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.True(result.HasAltPhoto);
+ }
+
+ [Fact]
+ public async Task GetUserInfoAsync_CannotViewDirectoryDetail_SkipsAltPhotoCheck()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-nodetail", "mothra-nodetail"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var photoService = Substitute.For();
+ photoService.HasAlternatePhotoAsync(Arg.Any(), Arg.Any()).Returns(true);
+
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), photoService);
+ var permissions = new UserInfoViewPermissions { CanViewDirectoryDetail = false };
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-nodetail", null, permissions);
+
+ // Assert - permissions gate the fetch itself, so a requester without directory-detail
+ // access shouldn't trigger a photo-service call at all (matching the pattern the other
+ // Populate*Async calls already follow).
+ Assert.NotNull(result);
+ Assert.False(result.HasAltPhoto);
+ await photoService.DidNotReceive().HasAlternatePhotoAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task PopulateEmployeeInfoAsync_PopulatesDetails()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ var coursesOptions = CreateInMemoryOptions();
+
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-emp", "mothra-emp", employeeId: "emp-789", employeePKey: "101"));
+ aaudSetup.Employees.Add(new Employee
+ {
+ EmpPKey = "101",
+ EmpTermCode = "202610",
+ EmpPrimaryTitle = "Senior Developer",
+ EmpSchoolDivision = "SVM Dean's Office",
+ EmpStatus = "A",
+ EmpHomeDept = "SVM Dean",
+ EmpEffortHomeDept = "SVM Effort Dept",
+ EmpTeachingHomeDept = "SVM Teaching Dept",
+ EmpTeachingPercentFulltime = 85,
+ EmpClientid = "1",
+ EmpAltDeptCode = "",
+ EmpCbuc = ""
+ });
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using (var coursesSetup = new CoursesContext(coursesOptions))
+ {
+ coursesSetup.Terminfos.Add(new Terminfo
+ {
+ TermCode = "202610",
+ TermCurrentTermMulti = true,
+ TermAcademicYear = "",
+ TermDesc = "",
+ TermCollCode = "01",
+ TermStartDate = DateTime.Today,
+ TermEndDate = DateTime.Today,
+ TermCurrentTerm = true,
+ TermTermType = ""
+ });
+ await coursesSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(coursesOptions);
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-emp", null, AllPermissions);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.True(result.IsEmployee);
+ Assert.Equal("Senior Developer", result.EmployeePrimaryTitle);
+ Assert.Equal("SVM Dean's Office", result.EmployeeSchoolDivision);
+ Assert.Equal("A", result.EmployeeStatus);
+ Assert.Equal("202610", result.EmployeeTerm);
+ Assert.Equal("SVM Dean", result.EmployeeHomeDepartment);
+ Assert.Equal("SVM Effort Dept", result.EmployeeEffortHomeDepartment);
+ Assert.Equal("SVM Teaching Dept", result.EmployeeTeachingHomeDepartment);
+ Assert.Equal("85", result.EmployeeTeachingPercentFulltime);
+ }
+
+ [Fact]
+ public async Task PopulateIDCardsAsync_ExecutesStatusAndReasonJoins()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-cards", "mothra-cards", "carduser"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var idcardsOptions = CreateInMemoryOptions();
+ using (var idcardsSetup = new IDCardsContext(idcardsOptions))
+ {
+ idcardsSetup.IdCards.Add(new IdCard
+ {
+ IdCardLoginId = "carduser",
+ IdCardNumber = 987654321,
+ IdCardDisplayName = "CardDisplayName",
+ IdCardLastName = "CardLastName",
+ IdCardLine2 = "Line 2 Text",
+ IdCardCurrentStatus = "A",
+ IdcardDeactivatedReason = "L",
+ IdCardAppliedDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified),
+ IdCardIssueDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Unspecified),
+ IdcardDeactivatedDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Unspecified)
+ });
+ idcardsSetup.DvtCardStatuses.Add(new DvtCardStatus
+ {
+ DvtStatusCode = "A",
+ DvtStatusDesc = "Active Card Status",
+ DvtStatusVoidable = "",
+ DvtStatusDupOk = ""
+ });
+ idcardsSetup.DvtReasons.Add(new DvtReason
+ {
+ DvtReasonCode = "L",
+ DvtReasonDesc = "Lost Card",
+ DvtReasonVoidable = "",
+ DvtReasonDupOk = ""
+ });
+ await idcardsSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(idcardsOptions);
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-cards", null, AllPermissions);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result.IDCards);
+ var card = result.IDCards[0];
+ Assert.Equal("987654321", card.Number);
+ Assert.Equal("CardDisplayName", card.DisplayName);
+ Assert.Equal("CardLastName", card.LastName);
+ Assert.Equal("Line 2 Text", card.Line2);
+ Assert.Equal("Active Card Status", card.StatusDescription);
+ Assert.Equal("Lost Card", card.DeactivatedReason);
+ Assert.Equal(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), card.Applied);
+ Assert.Equal(new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Unspecified), card.Issued);
+ Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Unspecified), card.Deactivated);
+ }
+
+ [Fact]
+ public async Task PopulateKeysAsync_MapsKeyDetails()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-keys", "mothra-keys"));
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-issuer", "mothra-issuer", "issuer", displayFullName: "John KeyIssuer"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var keysOptions = CreateInMemoryOptions();
+ using (var keysSetup = new KeysContext(keysOptions))
+ {
+ keysSetup.Keys.Add(new Key
+ {
+ KeyId = 202,
+ KeyNumber = "K99",
+ AccessDescription = "Main Gate Access",
+ CreatedBy = "system"
+ });
+ keysSetup.KeyAssignments.Add(new KeyAssignment
+ {
+ KeyId = 202,
+ AssignedTo = "mothra-keys",
+ CutNumber = "C1",
+ IssuedDate = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Unspecified),
+ IssuedBy = "mothra-issuer",
+ Deleted = null
+ });
+ await keysSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(keysOptions);
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-keys", null, AllPermissions);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result.Keys);
+ var assignment = result.Keys[0];
+ Assert.Equal("Main Gate Access", assignment.AccessDescription);
+ Assert.Equal("K99", assignment.KeyNumber);
+ Assert.Equal("C1", assignment.CutNumber);
+ Assert.Equal(new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Unspecified), assignment.IssuedDate);
+ Assert.Equal("John KeyIssuer", assignment.IssuedBy);
+ }
+
+ [Fact]
+ public async Task PopulateLoansAsync_MapsLoanDetails()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-loans", "mothra-loans", pidm: "pidm-loans"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var loansOptions = CreateInMemoryOptions();
+ using (var loansSetup = new EquipmentLoanContext(loansOptions))
+ {
+ var newLoan = new Loan
+ {
+ LoanId = 505,
+ LoanPidm = "pidm-loans",
+ LoanTechPidm = "tech-pidm-loans",
+ LoanDate = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Unspecified),
+ LoanDueDate = new DateTime(2026, 5, 10, 0, 0, 0, DateTimeKind.Unspecified),
+ LoanComments = "Projector Loan"
+ };
+ loansSetup.Loans.Add(newLoan);
+
+ var asset = new Asset
+ {
+ AssetId = 808,
+ AssetName = "Epson Projector 4K",
+ AssetStatus = "A"
+ };
+ loansSetup.Assets.Add(asset);
+
+ loansSetup.LoanItems.Add(new LoanItem
+ {
+ LoanitemLoanid = 505,
+ LoanitemAssetid = 808,
+ LoanitemAsset = asset,
+ LoanitemCheckout = DateTime.Today,
+ LoanitemCheckoutPidm = "checkout-pidm"
+ });
+
+ await loansSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(loansOptions);
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-loans", null, AllPermissions);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result.Loans);
+ var loanResult = result.Loans[0];
+ Assert.Equal("Epson Projector 4K", loanResult.AssetName);
+ Assert.Equal(new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Unspecified), loanResult.LoanDate);
+ Assert.Equal(new DateTime(2026, 5, 10, 0, 0, 0, DateTimeKind.Unspecified), loanResult.DueDate);
+ Assert.Equal("Projector Loan", loanResult.Comments);
+ }
+
+ [Fact]
+ public async Task PopulateSystemRolesAndPermissions_QueriesCorrectly()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-raps", "mothra-raps"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var rapsOptions = CreateInMemoryOptions();
+ using (var rapsSetup = new RAPSContext(rapsOptions))
+ {
+ var role = new TblRole
+ {
+ RoleId = 10,
+ Role = "CN=DirectoryAdmin,OU=Roles,DC=viper",
+ DisplayName = "DirectoryAdmin"
+ };
+ rapsSetup.TblRoles.Add(role);
+
+ rapsSetup.TblRoleMembers.Add(new TblRoleMember
+ {
+ RoleId = 10,
+ MemberId = "mothra-raps",
+ Role = role,
+ ViewName = null
+ });
+
+ var perm1 = new TblPermission
+ {
+ PermissionId = 50,
+ Permission = "RAPS.User.View"
+ };
+ var perm2 = new TblPermission
+ {
+ PermissionId = 60,
+ Permission = "API.Data.Read"
+ };
+ rapsSetup.TblPermissions.AddRange(perm1, perm2);
+
+ // Assign perm1 via role
+ rapsSetup.TblRolePermissions.Add(new TblRolePermission
+ {
+ RoleId = 10,
+ PermissionId = 50,
+ Access = 1
+ });
+
+ // Assign perm2 directly
+ rapsSetup.TblMemberPermissions.Add(new TblMemberPermission
+ {
+ MemberId = "mothra-raps",
+ PermissionId = 60,
+ Access = 1,
+ StartDate = null,
+ EndDate = null
+ });
+
+ await rapsSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(rapsOptions);
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-raps", null, AllPermissions);
+
+ // Assert
+ Assert.NotNull(result);
+
+ // Check formatted Roles
+ Assert.Single(result.SystemRoles);
+ Assert.Equal("VIPER", result.SystemRoles[0].System);
+ Assert.Equal("DirectoryAdmin", result.SystemRoles[0].DisplayName);
+
+ // Check permissions categories
+ var rapsPermCategory = result.SystemPermissions.FirstOrDefault(p => p.Category == "RAPS");
+ Assert.NotNull(rapsPermCategory);
+ Assert.Equal(1, rapsPermCategory.Count);
+ Assert.Equal("RAPS.User.View", rapsPermCategory.Permissions[0]);
+
+ var apiPermCategory = result.SystemPermissions.FirstOrDefault(p => p.Category == "API");
+ Assert.NotNull(apiPermCategory);
+ Assert.Equal(1, apiPermCategory.Count);
+ Assert.Equal("API.Data.Read", apiPermCategory.Permissions[0]);
+ }
+
+ [Fact]
+ public async Task PopulateSystemRolesAsync_IncludesViewAssignedRoles()
+ {
+ // Arrange - a role membership added by a RAPS population view (e.g. a student
+ // roster view) should still show up on the UserInfo page, not just roles that
+ // were assigned to the individual directly.
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-student", "mothra-student"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var rapsOptions = CreateInMemoryOptions();
+ using (var rapsSetup = new RAPSContext(rapsOptions))
+ {
+ var role = new TblRole
+ {
+ RoleId = 20,
+ Role = "CN=Student,OU=Roles,DC=viper",
+ DisplayName = "Student"
+ };
+ rapsSetup.TblRoles.Add(role);
+
+ rapsSetup.TblRoleMembers.Add(new TblRoleMember
+ {
+ RoleId = 20,
+ MemberId = "mothra-student",
+ Role = role,
+ ViewName = "VwStudentsDvm"
+ });
+
+ await rapsSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(rapsOptions);
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-student", null, AllPermissions);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result.SystemRoles);
+ Assert.Equal("VIPER", result.SystemRoles[0].System);
+ Assert.Equal("Student", result.SystemRoles[0].DisplayName);
+ }
+
+ [Fact]
+ public async Task PopulateSystemRolesAsync_ExcludesExpiredOrNotYetStartedRoleMemberships()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-expired", "mothra-expired"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var rapsOptions = CreateInMemoryOptions();
+ using (var rapsSetup = new RAPSContext(rapsOptions))
+ {
+ var expiredRole = new TblRole
+ {
+ RoleId = 30,
+ Role = "CN=Expired,OU=Roles,DC=viper",
+ DisplayName = "Expired"
+ };
+ var futureRole = new TblRole
+ {
+ RoleId = 31,
+ Role = "CN=Future,OU=Roles,DC=viper",
+ DisplayName = "Future"
+ };
+ var activeRole = new TblRole
+ {
+ RoleId = 32,
+ Role = "CN=Active,OU=Roles,DC=viper",
+ DisplayName = "Active"
+ };
+ rapsSetup.TblRoles.AddRange(expiredRole, futureRole, activeRole);
+
+ rapsSetup.TblRoleMembers.Add(new TblRoleMember
+ {
+ RoleId = 30,
+ MemberId = "mothra-expired",
+ Role = expiredRole,
+ EndDate = DateTime.Today.AddDays(-1)
+ });
+ rapsSetup.TblRoleMembers.Add(new TblRoleMember
+ {
+ RoleId = 31,
+ MemberId = "mothra-expired",
+ Role = futureRole,
+ StartDate = DateTime.Today.AddDays(1)
+ });
+ rapsSetup.TblRoleMembers.Add(new TblRoleMember
+ {
+ RoleId = 32,
+ MemberId = "mothra-expired",
+ Role = activeRole,
+ StartDate = DateTime.Today.AddDays(-1),
+ EndDate = DateTime.Today.AddDays(1)
+ });
+
+ await rapsSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(rapsOptions);
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-expired", null, AllPermissions);
+
+ // Assert - only the active role membership should show up
+ Assert.NotNull(result);
+ Assert.Single(result.SystemRoles);
+ Assert.Equal("Active", result.SystemRoles[0].DisplayName);
+ }
+
+ [Fact]
+ public async Task PopulateUCPathHistoryAsync_UsesJobEffectiveDateNotPositionEffectiveDate()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-ucpath", "mothra-ucpath", employeeId: "emp-ucpath"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ // Effdt (the job record's own effective date) and PositionEffdt (the position
+ // record's effective date) are deliberately different, so the test fails if the
+ // history row ever goes back to showing the position's date instead of the job's.
+ var historyRows = new List
+ {
+ new()
+ {
+ Emplid = "emp-ucpath",
+ Name = "Jane Doe",
+ LastName = "Doe",
+ FirstName = "Jane",
+ Jobcode = "001234",
+ JobcodeDesc = "Lecturer",
+ JobcodeShortDesc = "Lect",
+ Jobgroup = "G1",
+ JobgroupDesc = "Group 1",
+ FlsaStatus = "E",
+ JobcodeUnionCode = "99",
+ Deptid = "062000",
+ DeptDesc = "VetMed Dean",
+ DeptCd = "062000",
+ DeptShortDesc = "VM Dean",
+ SubDivCd = "01",
+ SubDivDesc = "SVM",
+ JobStatusDesc = "Active",
+ JobStatus1 = "A",
+ ActionDescr = "Pay Rate Change",
+ Effdt = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Unspecified),
+ PositionEffdt = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Unspecified),
+ PositionNbr = null,
+ ReportsTo = null
+ }
+ };
+ var mockHistorySet = historyRows.BuildMockDbSet();
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var pps = Substitute.ForPartsOf(CreateInMemoryOptions());
+ pps.VwPersonJobPositionAlls.Returns(mockHistorySet);
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-ucpath", null, AllPermissions);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result.UCPathHistory);
+ Assert.Equal(new DateOnly(2026, 3, 1), result.UCPathHistory[0].JobEffectiveDate);
+ Assert.NotEqual(new DateOnly(2025, 1, 1), result.UCPathHistory[0].JobEffectiveDate);
+ }
+
+ [Fact]
+ public async Task PopulateIamInfoAsync_CallsApiAndMapsCollections()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-caller", "mothra-caller"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ // Mock responses for SearchForPerson (iam/people/search) and GetEmployeeAssociations (iam/associations/pps/{iamId})
+ var httpFactory = CreateMockHttpClientFactory(request =>
+ {
+ var uri = request.RequestUri?.ToString() ?? "";
+ if (uri.Contains("iam/people/search"))
+ {
+ var json = @"
+ {
+ ""responseStatus"": 0,
+ ""responseData"": {
+ ""results"": [
+ {
+ ""iamId"": ""iam-caller"",
+ ""oFirstName"": ""Jane"",
+ ""oLastName"": ""Doe"",
+ ""oFullName"": ""Full Name From IAM"",
+ ""ppsId"": ""pps-111"",
+ ""isEmployee"": true,
+ ""isStudent"": false
+ }
+ ]
+ }
+ }";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json")
+ };
+ }
+ else if (uri.Contains("iam/associations/pps/iam-caller"))
+ {
+ var json = @"
+ {
+ ""responseStatus"": 0,
+ ""responseData"": {
+ ""results"": [
+ {
+ ""iamId"": ""iam-caller"",
+ ""titleDisplayName"": ""Manager"",
+ ""titleCode"": ""001100"",
+ ""deptDisplayName"": ""VetMed Dean"",
+ ""deptCode"": ""062000"",
+ ""percentFullTime"": ""1.0"",
+ ""assocStartDate"": ""2026-01-01""
+ }
+ ]
+ }
+ }";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json")
+ };
+ }
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ // Act
+ var result = await service.GetUserInfoAsync("iam-caller", null, AllPermissions);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal("pps-111", result.PPSId);
+ Assert.Equal("Full Name From IAM", result.OFullName);
+
+ // Check populated collections
+ Assert.Single(result.IamPeople);
+ Assert.Equal("Full Name From IAM", result.IamPeople[0].OFullName);
+
+ Assert.Single(result.IamAssociations);
+ Assert.Equal("Manager", result.IamAssociations[0].TitleDisplayName);
+ Assert.Equal("VetMed Dean", result.IamAssociations[0].DeptDisplayName);
+ }
+
+ [Fact]
+ public async Task PopulateInstinctInfoAsync_ResolvesEndpointAndQueriesGraphQL()
+ {
+ // Arrange
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-inst", "mothra-inst", firstName: "Jane", lastName: "Doe", middleName: "Alex"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ // HTTP Mock for token request and graphql user lookup
+ var httpFactory = CreateMockHttpClientFactory(request =>
+ {
+ var uri = request.RequestUri?.ToString() ?? "";
+ if (uri.Contains("auth/token"))
+ {
+ var tokenJson = "{\"access_token\": \"jane-doe-token-key\", \"expires_in\": 86400}";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(tokenJson, Encoding.UTF8, "application/json")
+ };
+ }
+ else if (uri.Contains("query="))
+ {
+ var searchJson = @"
+ {
+ ""data"": {
+ ""searchUsers"": [
+ {
+ ""id"": ""inst-jane-doe"",
+ ""instinctId"": ""inst-jane-doe"",
+ ""status"": ""Active"",
+ ""isActive"": true,
+ ""username"": ""jdoe"",
+ ""nameFirst"": ""Jane"",
+ ""nameLast"": ""Doe"",
+ ""roles"": [
+ { ""label"": ""Doctor"" }
+ ]
+ }
+ ]
+ }
+ }";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(searchJson, Encoding.UTF8, "application/json")
+ };
+ }
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ // Act & Assert with temporary HttpHelper configuration
+ var mockEnv = Substitute.For();
+ HttpHelper.Configure(_memoryCache, _configuration, mockEnv, null, null, null, null);
+ try
+ {
+ var result = await service.GetUserInfoAsync("iam-inst", null, AllPermissions);
+
+ Assert.NotNull(result);
+ Assert.Null(result.InstinctInfo?.ErrorMessage);
+ Assert.Equal("inst-jane-doe", result.InstinctId);
+ Assert.Equal("jdoe", result.InstinctUsername);
+ Assert.Equal("Active", result.InstinctStatus);
+ Assert.True(result.InstinctIsActive);
+ Assert.Single(result.InstinctRoles);
+ Assert.Equal("Doctor", result.InstinctRoles[0]);
+ }
+ finally
+ {
+ HttpHelper.Configure(null, null, null!, null, null, null, null);
+ }
+ }
+
+ [Fact]
+ public async Task PopulateInstinctInfoAsync_NullSearchUsersWithNoErrors_IsNotTreatedAsFailure()
+ {
+ // searchUsers is a nullable list in the schema, so a query that runs fine and simply
+ // finds nobody can come back as "searchUsers": null instead of an empty array - most
+ // VIPER users don't have an Instinct account, and that must not surface as an error.
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-noinst", "mothra-noinst", firstName: "No", lastName: "Account"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var httpFactory = CreateMockHttpClientFactory(request =>
+ {
+ var uri = request.RequestUri?.ToString() ?? "";
+ if (uri.Contains("auth/token"))
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"access_token\": \"tok\", \"expires_in\": 86400}", Encoding.UTF8, "application/json")
+ };
+ }
+ if (uri.Contains("query="))
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(@"{ ""data"": { ""searchUsers"": null } }", Encoding.UTF8, "application/json")
+ };
+ }
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For());
+
+ var mockEnv = Substitute.For();
+ HttpHelper.Configure(_memoryCache, _configuration, mockEnv, null, null, null, null);
+ try
+ {
+ var result = await service.GetUserInfoAsync("iam-noinst", null, AllPermissions);
+
+ Assert.NotNull(result);
+ Assert.Null(result.InstinctInfo?.ErrorMessage);
+ Assert.Null(result.InstinctId);
+ Assert.DoesNotContain("Instinct", result.UnavailableSections);
+ }
+ finally
+ {
+ HttpHelper.Configure(null, null, null!, null, null, null, null);
+ }
+ }
+
+ [Fact]
+ public async Task PopulateInstinctInfoAsync_GraphQLErrors_IsTreatedAsFailure()
+ {
+ // Unlike a clean "no match", a populated "errors" array is GraphQL's own signal that
+ // the query itself failed server-side - that's a real failure and should surface as
+ // one, distinct from someone simply not having an Instinct account.
+ var aaudOptions = CreateInMemoryOptions();
+ using (var aaudSetup = new AAUDContext(aaudOptions))
+ {
+ aaudSetup.AaudUsers.Add(CreateTestUser("iam-gqlerr", "mothra-gqlerr", firstName: "Gql", lastName: "Error"));
+ await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var httpFactory = CreateMockHttpClientFactory(request =>
+ {
+ var uri = request.RequestUri?.ToString() ?? "";
+ if (uri.Contains("auth/token"))
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"access_token\": \"tok\", \"expires_in\": 86400}", Encoding.UTF8, "application/json")
+ };
+ }
+ if (uri.Contains("query="))
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ @"{ ""data"": null, ""errors"": [ { ""message"": ""Downstream service unavailable"" } ] }",
+ Encoding.UTF8, "application/json")
+ };
+ }
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+
+ using var aaud = new AAUDContext(aaudOptions);
+ using var raps = new RAPSContext(CreateInMemoryOptions());
+ using var courses = new CoursesContext(CreateInMemoryOptions());
+ using var loans = new EquipmentLoanContext(CreateInMemoryOptions());
+ using var pps = new PPSContext(CreateInMemoryOptions());
+ using var idcards = new IDCardsContext(CreateInMemoryOptions());
+ using var keys = new KeysContext(CreateInMemoryOptions());
+
+ var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For |