using System.Text.Json; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Xunit; namespace JobTrackerApi.Tests; public sealed class OperationsControllerTests { [Fact] public async Task Operation_api_lists_mutates_and_hides_other_owner_records() { await using var fixture = await Fixture.CreateAsync(); Guid ownId; Guid otherId; await using (var db = fixture.Context("user-1")) ownId = (await fixture.Operations(db).CreateAsync(Request("own"), default)).Operation.Id; await using (var db = fixture.Context("user-2")) otherId = (await fixture.Operations(db).CreateAsync(Request("other"), default)).Operation.Id; await using var ownerDb = fixture.Context("user-1"); var controller = fixture.OperationsController(ownerDb); var listed = Assert.IsType((await controller.List(cancellationToken: default)).Result); var item = Assert.Single(Assert.IsAssignableFrom>(listed.Value)); Assert.Equal(ownId, item.Id); Assert.IsType((await controller.Get(otherId, default)).Result); var cancelled = Assert.IsType((await controller.Cancel(ownId, default)).Result); Assert.Equal(ownId, Assert.IsType(cancelled.Value).Id); Assert.Equal(OperationStatuses.Cancelled, Assert.IsType(cancelled.Value).Status); Assert.IsType((await controller.Cancel(ownId, default)).Result); var retried = Assert.IsType((await controller.Retry(ownId, default)).Result); Assert.Equal(ownId, Assert.IsType(retried.Value).Id); Assert.Equal(OperationStatuses.Queued, Assert.IsType(retried.Value).Status); Assert.IsType((await controller.Retry(ownId, default)).Result); await using var persistedDb = fixture.Context("user-1"); var persisted = Assert.Single(await persistedDb.UserOperations.AsNoTracking().ToListAsync()); Assert.Equal(ownId, persisted.Id); Assert.Equal(OperationStatuses.Queued, persisted.Status); Assert.Null(persisted.StartedAtUtc); Assert.Null(persisted.CompletedAtUtc); Assert.Null(persisted.CancellationRequestedAtUtc); var serialized = JsonSerializer.Serialize(item); Assert.DoesNotContain("IdempotencyKey", serialized, StringComparison.Ordinal); Assert.DoesNotContain("LeaseToken", serialized, StringComparison.Ordinal); Assert.DoesNotContain("FailureMessage", serialized, StringComparison.Ordinal); Assert.DoesNotContain("ResultReference", serialized, StringComparison.Ordinal); Assert.DoesNotContain("Provider", serialized, StringComparison.Ordinal); Assert.DoesNotContain("Model", serialized, StringComparison.Ordinal); } [Fact] public async Task Notification_api_is_owner_scoped_and_read_dismiss_are_idempotent() { await using var fixture = await Fixture.CreateAsync(); Guid notificationId; await using (var db = fixture.Context("user-1")) { var operations = fixture.Operations(db); var operation = await operations.CreateAsync(Request("notification"), default); Assert.True(await operations.RequestCancellationAsync(operation.Operation.Id, default)); notificationId = (await db.UserNotifications.AsNoTracking().SingleAsync()).Id; } await using (var otherDb = fixture.Context("user-2")) { var other = fixture.NotificationsController(otherDb); Assert.Empty(Assert.IsAssignableFrom>( Assert.IsType((await other.List(cancellationToken: default)).Result).Value)); Assert.IsType(await other.MarkRead(notificationId, default)); Assert.IsType(await other.Dismiss(notificationId, default)); } await using var ownerDb = fixture.Context("user-1"); var controller = fixture.NotificationsController(ownerDb); var operationBefore = JsonSerializer.Serialize(await ownerDb.UserOperations.AsNoTracking().SingleAsync()); Assert.Single(Assert.IsAssignableFrom>( Assert.IsType((await controller.List(cancellationToken: default)).Result).Value)); Assert.IsType(await controller.MarkRead(notificationId, default)); var read = await ownerDb.UserNotifications.AsNoTracking().SingleAsync(); Assert.Equal(notificationId, read.Id); Assert.NotNull(read.ReadAtUtc); Assert.IsType(await controller.MarkRead(notificationId, default)); Assert.Equal(read.ReadAtUtc, (await ownerDb.UserNotifications.AsNoTracking().SingleAsync()).ReadAtUtc); Assert.IsType(await controller.Dismiss(notificationId, default)); var dismissed = await ownerDb.UserNotifications.AsNoTracking().SingleAsync(); Assert.NotNull(dismissed.DismissedAtUtc); Assert.IsType(await controller.Dismiss(notificationId, default)); Assert.Empty(Assert.IsAssignableFrom>( Assert.IsType((await controller.List(cancellationToken: default)).Result).Value)); await using var persistedDb = fixture.Context("user-1"); var persisted = await persistedDb.UserNotifications.AsNoTracking().SingleAsync(); Assert.Equal(notificationId, persisted.Id); Assert.Equal(read.CreatedAtUtc, persisted.CreatedAtUtc); Assert.Equal(read.ReadAtUtc, persisted.ReadAtUtc); Assert.Equal(dismissed.DismissedAtUtc, persisted.DismissedAtUtc); Assert.Equal(operationBefore, JsonSerializer.Serialize(await persistedDb.UserOperations.AsNoTracking().SingleAsync())); } [Fact] public async Task Operation_and_notification_endpoints_validate_bounds_and_require_local_auth() { await using var fixture = await Fixture.CreateAsync(); await using var db = fixture.Context("user-1"); Assert.IsType((await fixture.OperationsController(db).List(0, default)).Result); Assert.IsType((await fixture.NotificationsController(db).List(101, default)).Result); foreach (var type in new[] { typeof(OperationsController), typeof(NotificationsController) }) { var authorize = Assert.Single(type.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true).Cast()); Assert.Equal("local", authorize.AuthenticationSchemes); } } [Fact] public async Task Operation_and_notification_DTOs_preserve_all_UTC_instants_after_SQLite_roundtrips() { await using var fixture = await Fixture.CreateAsync(); var created = new DateTime(2026, 10, 16, 8, 9, 10, DateTimeKind.Utc).AddTicks(1234567); var started = created.AddMinutes(1).AddTicks(2); var completed = created.AddMinutes(2).AddTicks(3); var deadline = created.AddMinutes(30).AddTicks(4); var cancellationRequested = created.AddSeconds(90).AddTicks(5); var noticeCreated = completed.AddTicks(6); var noticeRead = noticeCreated.AddMinutes(1).AddTicks(7); Guid operationId; var notificationId = Guid.NewGuid(); await using (var seedDb = fixture.Context("user-1")) { var operation = (await fixture.Operations(seedDb).CreateAsync(Request("utc-transport"), default)).Operation; operationId = operation.Id; operation.Status = OperationStatuses.Failed; operation.CreatedAtUtc = created; operation.StartedAtUtc = started; operation.CompletedAtUtc = completed; operation.DeadlineAtUtc = deadline; operation.CancellationRequestedAtUtc = cancellationRequested; seedDb.UserNotifications.Add(new UserNotification { Id = notificationId, OwnerUserId = "user-1", OperationId = operationId, Kind = "operation_failed", Title = "Recorded operation", Message = "Stored UTC contract", CreatedAtUtc = noticeCreated, ReadAtUtc = noticeRead, }); await seedDb.SaveChangesAsync(); } await using var readDb = fixture.Context("user-1"); var storedOperation = await readDb.UserOperations.AsNoTracking().SingleAsync(); var storedNotification = await readDb.UserNotifications.AsNoTracking().SingleAsync(); Assert.All(new[] { storedOperation.CreatedAtUtc, storedOperation.StartedAtUtc!.Value, storedOperation.CompletedAtUtc!.Value, storedOperation.DeadlineAtUtc!.Value, storedOperation.CancellationRequestedAtUtc!.Value, storedNotification.CreatedAtUtc, storedNotification.ReadAtUtc!.Value }, value => Assert.Equal(DateTimeKind.Unspecified, value.Kind)); var operationBefore = JsonSerializer.Serialize(storedOperation); var notificationBefore = JsonSerializer.Serialize(storedNotification); var operationController = fixture.OperationsController(readDb); var retrieved = Assert.IsType(Assert.IsType( (await operationController.Get(operationId, default)).Result).Value); var listed = Assert.Single(Assert.IsAssignableFrom>( Assert.IsType((await operationController.List(cancellationToken: default)).Result).Value)); var notification = Assert.Single(Assert.IsAssignableFrom>( Assert.IsType((await fixture.NotificationsController(readDb).List(cancellationToken: default)).Result).Value)); Assert.Equal(operationId, retrieved.Id); Assert.Equal(retrieved, listed); Assert.Equal(notificationId, notification.Id); Assert.Equal(operationId, notification.OperationId); // Web JSON serialization of controller DTOs; this fixture does not host HTTP. var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); foreach (var operation in new[] { retrieved, listed }) { var json = JsonSerializer.SerializeToElement(operation, options); Assert.Equal(new[] { "id", "taskType", "status", "subjectType", "createdAtUtc", "startedAtUtc", "completedAtUtc", "deadlineAtUtc", "cancellationRequestedAtUtc", "progressStage", "progressPercent", "failureCategory", "canCancel", "canRetry" }.Order(), json.EnumerateObject().Select(property => property.Name).Order()); AssertUtcJson(json, "createdAtUtc", created); AssertUtcJson(json, "startedAtUtc", started); AssertUtcJson(json, "completedAtUtc", completed); AssertUtcJson(json, "deadlineAtUtc", deadline); AssertUtcJson(json, "cancellationRequestedAtUtc", cancellationRequested); } var noticeJson = JsonSerializer.SerializeToElement(notification, options); Assert.Equal(new[] { "id", "operationId", "kind", "title", "message", "linkPath", "createdAtUtc", "readAtUtc" }.Order(), noticeJson.EnumerateObject().Select(property => property.Name).Order()); AssertUtcJson(noticeJson, "createdAtUtc", noticeCreated); AssertUtcJson(noticeJson, "readAtUtc", noticeRead); await using var verifyDb = fixture.Context("user-1"); Assert.Equal(operationBefore, JsonSerializer.Serialize(await verifyDb.UserOperations.AsNoTracking().SingleAsync())); Assert.Equal(notificationBefore, JsonSerializer.Serialize(await verifyDb.UserNotifications.AsNoTracking().SingleAsync())); } private static void AssertUtcJson(JsonElement json, string property, DateTime expected) { Assert.EndsWith("Z", json.GetProperty(property).GetString()); var instant = json.GetProperty(property).GetDateTimeOffset(); Assert.Equal(TimeSpan.Zero, instant.Offset); Assert.Equal(expected.Ticks, instant.UtcDateTime.Ticks); } private static CreateUserOperation Request(string key) => new("synthetic-test", key, "authorized", "local-only", "job", "42"); private sealed class CurrentUser(string? userId) : ICurrentUserService { public string? UserId { get; } = userId; } private sealed class Fixture : IAsyncDisposable { private readonly string _root; private readonly string _connectionString; private Fixture(string root) { _root = root; _connectionString = $"Data Source={Path.Combine(root, "api.db")};Default Timeout=5;Pooling=False"; } public static async Task CreateAsync() { var root = Path.Combine(Path.GetTempPath(), $"jobtracker-operation-api-{Guid.NewGuid():N}"); Directory.CreateDirectory(root); var fixture = new Fixture(root); await using var db = fixture.Context(null); await db.Database.EnsureCreatedAsync(); return fixture; } public JobTrackerContext Context(string? owner) { var options = new DbContextOptionsBuilder().UseSqlite(_connectionString).Options; return new JobTrackerContext(options, new CurrentUser(owner)); } public UserOperationStore Operations(JobTrackerContext db) => new(db, TimeProvider.System); public UserNotificationStore Notifications(JobTrackerContext db) => new(db, TimeProvider.System); public OperationsController OperationsController(JobTrackerContext db) => new(Operations(db)); public NotificationsController NotificationsController(JobTrackerContext db) => new(Notifications(db)); public ValueTask DisposeAsync() { if (Directory.Exists(_root)) Directory.Delete(_root, true); return ValueTask.CompletedTask; } } }