255 lines
14 KiB
C#
255 lines
14 KiB
C#
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<OkObjectResult>((await controller.List(cancellationToken: default)).Result);
|
|
var item = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<OperationDto>>(listed.Value));
|
|
Assert.Equal(ownId, item.Id);
|
|
Assert.IsType<NotFoundResult>((await controller.Get(otherId, default)).Result);
|
|
|
|
var cancelled = Assert.IsType<OkObjectResult>((await controller.Cancel(ownId, default)).Result);
|
|
Assert.Equal(ownId, Assert.IsType<OperationDto>(cancelled.Value).Id);
|
|
Assert.Equal(OperationStatuses.Cancelled, Assert.IsType<OperationDto>(cancelled.Value).Status);
|
|
Assert.IsType<ConflictObjectResult>((await controller.Cancel(ownId, default)).Result);
|
|
var retried = Assert.IsType<OkObjectResult>((await controller.Retry(ownId, default)).Result);
|
|
Assert.Equal(ownId, Assert.IsType<OperationDto>(retried.Value).Id);
|
|
Assert.Equal(OperationStatuses.Queued, Assert.IsType<OperationDto>(retried.Value).Status);
|
|
Assert.IsType<ConflictObjectResult>((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<IReadOnlyList<NotificationDto>>(
|
|
Assert.IsType<OkObjectResult>((await other.List(cancellationToken: default)).Result).Value));
|
|
Assert.IsType<NotFoundResult>(await other.MarkRead(notificationId, default));
|
|
Assert.IsType<NotFoundResult>(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<IReadOnlyList<NotificationDto>>(
|
|
Assert.IsType<OkObjectResult>((await controller.List(cancellationToken: default)).Result).Value));
|
|
Assert.IsType<NoContentResult>(await controller.MarkRead(notificationId, default));
|
|
var read = await ownerDb.UserNotifications.AsNoTracking().SingleAsync();
|
|
Assert.Equal(notificationId, read.Id);
|
|
Assert.NotNull(read.ReadAtUtc);
|
|
Assert.IsType<NoContentResult>(await controller.MarkRead(notificationId, default));
|
|
Assert.Equal(read.ReadAtUtc, (await ownerDb.UserNotifications.AsNoTracking().SingleAsync()).ReadAtUtc);
|
|
Assert.IsType<NoContentResult>(await controller.Dismiss(notificationId, default));
|
|
var dismissed = await ownerDb.UserNotifications.AsNoTracking().SingleAsync();
|
|
Assert.NotNull(dismissed.DismissedAtUtc);
|
|
Assert.IsType<NoContentResult>(await controller.Dismiss(notificationId, default));
|
|
Assert.Empty(Assert.IsAssignableFrom<IReadOnlyList<NotificationDto>>(
|
|
Assert.IsType<OkObjectResult>((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<BadRequestObjectResult>((await fixture.OperationsController(db).List(0, default)).Result);
|
|
Assert.IsType<BadRequestObjectResult>((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<AuthorizeAttribute>());
|
|
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<OperationDto>(Assert.IsType<OkObjectResult>(
|
|
(await operationController.Get(operationId, default)).Result).Value);
|
|
var listed = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<OperationDto>>(
|
|
Assert.IsType<OkObjectResult>((await operationController.List(cancellationToken: default)).Result).Value));
|
|
var notification = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<NotificationDto>>(
|
|
Assert.IsType<OkObjectResult>((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<Fixture> 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<JobTrackerContext>().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;
|
|
}
|
|
}
|
|
}
|