using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
///
/// End-to-end resolver behaviour: gate -> cache -> full-pull -> per-ID fallback -> 404 ->
/// transient. Uses fakes (no real Azure/Graph dependencies).
///
public class ScriptDisplayNameResolverTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
[Fact]
public async Task Returns_all_null_when_permission_missing()
{
var fix = new Fixture();
// No context registered -> detector returns null.
var refs = new[] { new ScriptRef(ScriptKind.Platform, "abc") };
var result = await fix.Sut.ResolveAsync(TenantId, refs);
Assert.Single(result);
Assert.Null(result[refs[0]]);
Assert.Empty(fix.Http.Requests); // Graph never called when permission gate fails
}
[Fact]
public async Task Cache_hit_returns_displayName_without_graph_call()
{
var fix = new Fixture();
fix.GivenPermission();
var r = new ScriptRef(ScriptKind.Platform, "abc-123");
fix.Repo.Entries[(TenantId, r.Kind, r.Id)] = new ScriptDisplayNameEntry
{
TenantId = TenantId,
Kind = r.Kind,
ScriptId = r.Id,
DisplayName = "MyScript.ps1",
FetchedAt = fix.FixedNow,
IsNotFound = false,
};
// Meta row exists and is fresh -> no full-pull path.
fix.Repo.Meta[(TenantId, r.Kind)] = new ScriptNameCacheMeta
{
TenantId = TenantId,
Kind = r.Kind,
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
};
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Equal("MyScript.ps1", result[r]);
Assert.Empty(fix.Http.Requests);
}
[Fact]
public async Task Cold_tenant_triggers_full_pull_and_populates_cache()
{
var fix = new Fixture();
fix.GivenPermission();
var r = new ScriptRef(ScriptKind.Platform, "id-1");
fix.Http.When("deviceManagementScripts?", HttpStatusCode.OK,
"{\"value\":[" +
"{\"id\":\"id-1\",\"displayName\":\"Bootstrap\",\"fileName\":\"bootstrap.ps1\"}," +
"{\"id\":\"id-2\",\"displayName\":\"Cleanup\",\"fileName\":\"cleanup.ps1\"}" +
"]}");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Equal("Bootstrap", result[r]);
Assert.Single(fix.Http.Requests);
// Both list entries cached for future calls.
Assert.Equal(2, fix.Repo.Entries.Count);
Assert.True(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
}
[Fact]
public async Task NetNew_id_after_recent_full_pull_uses_per_id_fallback()
{
var fix = new Fixture();
fix.GivenPermission();
// Recent full-pull means we DON'T re-pull on a miss.
fix.Repo.Meta[(TenantId, ScriptKind.Remediation)] = new ScriptNameCacheMeta
{
TenantId = TenantId,
Kind = ScriptKind.Remediation,
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
};
var r = new ScriptRef(ScriptKind.Remediation, "net-new-id");
fix.Http.When("deviceHealthScripts/net-new-id", HttpStatusCode.OK,
"{\"id\":\"net-new-id\",\"displayName\":\"Net-New\"}");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Equal("Net-New", result[r]);
Assert.Single(fix.Http.Requests);
Assert.Contains("deviceHealthScripts/net-new-id", fix.Http.Requests[0]);
// Per-ID fetch also writes to cache.
Assert.True(fix.Repo.Entries.ContainsKey((TenantId, ScriptKind.Remediation, "net-new-id")));
}
[Fact]
public async Task PerId_404_writes_negative_cache_returns_null()
{
var fix = new Fixture();
fix.GivenPermission();
fix.Repo.Meta[(TenantId, ScriptKind.Platform)] = new ScriptNameCacheMeta
{
TenantId = TenantId,
Kind = ScriptKind.Platform,
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
};
var r = new ScriptRef(ScriptKind.Platform, "deleted-id");
fix.Http.When("deviceManagementScripts/deleted-id", HttpStatusCode.NotFound, "{\"error\":{\"code\":\"NotFound\"}}");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]);
Assert.True(fix.Repo.Entries.ContainsKey((TenantId, ScriptKind.Platform, "deleted-id")));
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
}
[Fact]
public async Task Empty_refs_returns_empty_dict()
{
var fix = new Fixture();
fix.GivenPermission();
var result = await fix.Sut.ResolveAsync(TenantId, Array.Empty());
Assert.Empty(result);
Assert.Empty(fix.Http.Requests);
}
[Fact]
public async Task Graph_5xx_during_full_pull_returns_null_without_throwing()
{
var fix = new Fixture();
fix.GivenPermission();
fix.Http.When("deviceManagementScripts?", HttpStatusCode.InternalServerError, "{}");
var r = new ScriptRef(ScriptKind.Platform, "abc");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]);
}
[Fact]
public async Task FullPull_429_suppresses_per_id_fallback_for_same_kind()
{
// Finding 2: when full-pull is throttled (429), per-ID fallback would just
// amplify the throttle. We must abort per-ID for that kind.
var fix = new Fixture();
fix.GivenPermission();
fix.Http.When("deviceManagementScripts?", (HttpStatusCode)429, "{\"error\":{\"code\":\"TooManyRequests\"}}");
// Add the per-ID URL too — but the test asserts it should NOT be reached.
fix.Http.When("deviceManagementScripts/abc", HttpStatusCode.OK,
"{\"id\":\"abc\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
var r = new ScriptRef(ScriptKind.Platform, "abc");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]); // per-ID never ran
Assert.Single(fix.Http.Requests); // only the failed full-pull
Assert.Contains("deviceManagementScripts?", fix.Http.Requests[0]);
}
[Fact]
public async Task PerId_429_breaks_loop_on_first_transient()
{
// Finding 2: in the per-ID fallback path (after a recent full-pull), the
// first transient HTTP failure aborts the remaining per-ID calls so we
// don't dig the throttling hole deeper.
var fix = new Fixture();
fix.GivenPermission();
// Recent meta -> per-ID fallback path for net-new ids.
fix.Repo.Meta[(TenantId, ScriptKind.Remediation)] = new ScriptNameCacheMeta
{
TenantId = TenantId,
Kind = ScriptKind.Remediation,
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
};
fix.Http.When("deviceHealthScripts/first", (HttpStatusCode)429, "{}");
// Stub a happy second call to prove it would have succeeded if we kept going.
fix.Http.When("deviceHealthScripts/second", HttpStatusCode.OK,
"{\"id\":\"second\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
var refs = new[]
{
new ScriptRef(ScriptKind.Remediation, "first"),
new ScriptRef(ScriptKind.Remediation, "second"),
};
var result = await fix.Sut.ResolveAsync(TenantId, refs);
Assert.All(refs, r => Assert.Null(result[r]));
Assert.Single(fix.Http.Requests); // second call never issued
Assert.Contains("deviceHealthScripts/first", fix.Http.Requests[0]);
}
[Fact]
public async Task Negative_cache_hit_returns_null_no_graph_call()
{
var fix = new Fixture();
fix.GivenPermission();
var r = new ScriptRef(ScriptKind.Remediation, "deleted");
fix.Repo.Entries[(TenantId, r.Kind, r.Id)] = new ScriptDisplayNameEntry
{
TenantId = TenantId,
Kind = r.Kind,
ScriptId = r.Id,
DisplayName = null,
FetchedAt = fix.FixedNow - TimeSpan.FromMinutes(30),
IsNotFound = true,
};
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]);
Assert.Empty(fix.Http.Requests);
}
// ── Fixture ──────────────────────────────────────────────────────────────
private sealed class Fixture
{
public FakeGraphFeatureDetector Detector { get; } = new();
public FakeScriptNameCacheRepository Repo { get; } = new();
public StubHttpMessageHandler Http { get; } = new();
public StubHttpClientFactory HttpFactory { get; }
public ScriptDisplayNameResolver Sut { get; }
public DateTimeOffset FixedNow { get; } = new(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
public Fixture()
{
HttpFactory = new StubHttpClientFactory(Http);
Sut = new ScriptDisplayNameResolver(
Detector, Repo, HttpFactory,
NullLogger.Instance,
new FakeTimeProvider(FixedNow));
}
public void GivenPermission()
{
Detector.ContextByTenant[TenantId] = new GraphTenantTokenContext
{
TenantId = TenantId,
AccessToken = "fake-token",
GrantedRoles = new HashSet(StringComparer.OrdinalIgnoreCase)
{
GraphAppPermissions.DeviceManagementScriptsReadAll,
},
ExpiresAt = FixedNow.AddHours(1),
};
}
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}