Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,12 @@ Four projects with strict separation of concerns:
|---------|------|
| `Anichron.Core` | Single source of truth: domain models, `AnichronDbContext`, all Fluent API config, shared utilities (XXHash64, path parsing). Zero dependencies on other projects (every other project references Core, so a reference back would be circular and fail the build). |
| `Anichron.Infrastructure` | Shared host plumbing used by API and Worker: configuration (`DatabaseConfiguration` reads the connection from `POSTGRES_CONNECTION__*` env vars or Docker secrets; INI bootstrap; `ConfigurationList`), migration under a PostgreSQL advisory lock, and the API's read-only stores (`IProxyStore`, `IOriginalStore`). Each host wires its own DI. |
| `Anichron.API` | ASP.NET Core Minimal APIs (the only web project: `libraries-are-not-web` keeps ASP.NET Core out of Core, Infrastructure and Worker, and the two hosts never use each other) — owns all identity/auth/admin writes (users, storage configs, refresh tokens, invites, lockout); read-only for media data. Media endpoints so far: proxy serving (#43) and On This Day (`GET /api/v1/flashbacks/on-this-day`, #38); originals (#44) are deferred past v1.0. All routes prefixed `/api/v1/`. |
| `Anichron.API` | ASP.NET Core Minimal APIs (the only web project: `libraries-are-not-web` keeps ASP.NET Core out of Core, Infrastructure and Worker, and the two hosts never use each other) — owns all identity/auth/admin writes (users, storage configs, refresh tokens, invites, lockout); read-only for media data. Media endpoints so far: proxy serving (#43), On This Day (`GET /api/v1/flashbacks/on-this-day`, #38) and the caller's star/like/hide state (`PATCH /api/v1/assets/{id}/interaction`, #46); originals (#44) are deferred past v1.0. All routes prefixed `/api/v1/`. |
| `Anichron.Worker` | `BackgroundService`s — NAS crawling, EXIF extraction, FFmpeg transcoding, proxy generation, and the work queue's executor and scheduler (maintenance such as expired-refresh-token cleanup runs as work items); reconciliation is planned (Epic 4) |

**Write ownership is split by domain, not centralized.**
- **Worker is the only writer of *media* data** — `MediaAsset`, `Metadata`, `ProxyFile`, `Burst`, plus all reconciliation and soft-deletes happen here. The API never writes media: outside its DI wiring it never touches `AnichronDbContext` (architecture rule `api-reaches-data-through-repositories`), and the media write repository stays out of it (`only-worker-writes-media`). Its one identity-side write is the `TokenCleanup` work item's handler deleting expired refresh tokens.
- **API owns all *identity/auth/admin* writes** — registration, login/refresh-token issuance and revocation, lockout state, invites, and admin user/storage-config CRUD all persist through API services (`AuthService`, `AdminUserService`, `AdminStorageConfigService`, `TokenService`, etc.) via `IUnitOfWork.SaveChangesAsync`. The API is read-only *only with respect to media*. The one exception is `AssetInteraction` (star/like/hide, #46): per-user state, to be written by the API.
- **API owns all *identity/auth/admin* writes** — registration, login/refresh-token issuance and revocation, lockout state, invites, and admin user/storage-config CRUD all persist through API services (`AuthService`, `AdminUserService`, `AdminStorageConfigService`, `TokenService`, etc.) via `IUnitOfWork.SaveChangesAsync`. The API is read-only *only with respect to media*. The one exception is `AssetInteraction` (star/like/hide, #46): per-user state, written by the API through `IAssetInteractionWriter` (Core), which takes and returns plain records so no API type touches the entity. It checks only `VisibleTo` (never `NotHiddenFor`, so a hidden asset can be unhidden), and a first write that loses the `(UserId, AssetId)` unique race reloads and reapplies once.
- **Work items** are inserted only through `IWorkQueue` (architecture rule `only-work-queue-inserts-work-items`); every later state change belongs to the Worker's executor, and handlers live only in the Worker (`work-handlers-live-in-worker`). See "Work queue" below.
- Both hosts apply EF migrations at startup, serialised by a PostgreSQL advisory lock (`MigrateWithAdvisoryLockAsync`).

Expand Down
8 changes: 8 additions & 0 deletions src/Anichron.API.Tests.Unit/Endpoints/AssetDtoMapperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ public void LikedAsset_WeighsDouble()
.Should().Be(new AssetInteractionDto(false, true, false, 2.0));
}

[Theory]
[InlineData(true, false, false, 1.0)]
[InlineData(false, true, false, 2.0)]
[InlineData(false, false, true, 1.0)]
public void InteractionState_MapsEachFlagAndWeighsALikeDouble(bool starred, bool liked, bool hidden, double weight)
=> AssetDtoMapper.ToDto(new InteractionState(starred, liked, hidden))
.Should().Be(new AssetInteractionDto(starred, liked, hidden, weight));

[Theory]
[InlineData(MediaType.Image, "Image")]
[InlineData(MediaType.Video, "Video")]
Expand Down
135 changes: 135 additions & 0 deletions src/Anichron.API.Tests.Unit/Endpoints/MediaEndpointsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;

namespace Anichron.API.Tests.Unit.Endpoints;

Expand All @@ -29,6 +30,7 @@ private sealed class TestFixture
{
public FakeProxyStore Proxies { get; } = new();
public IMediaAssetReadRepository Assets { get; } = Substitute.For<IMediaAssetReadRepository>();
public IAssetInteractionWriter Interactions { get; } = Substitute.For<IAssetInteractionWriter>();

public void HasProxy(ProxyType type, string proxyPath, byte[]? bytes)
{
Expand All @@ -42,6 +44,7 @@ public Task<AuthenticatedTestApp> StartAsync()
=> AuthenticatedTestApp.StartAsync(api => api.MapMediaEndpoints(), services =>
{
services.AddSingleton(Assets);
services.AddSingleton(Interactions);
services.AddSingleton<IProxyStore>(Proxies);
});
}
Expand Down Expand Up @@ -191,4 +194,136 @@ public async Task GetProxy_Anonymous_ReturnsUnauthorizedAsync()

response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

// ==========================================================================
// PATCH interaction (#46)
// ==========================================================================

private static HttpRequestMessage Patch(object body, Guid? userId = null)
{
var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/assets/{AssetId}/interaction")
{
Content = JsonContent.Create(body),
};
if (userId is { } caller)
request.Headers.Add(AuthenticatedTestApp.UserHeader, caller.ToString());
return request;
}

[Fact]
public async Task PatchInteraction_OwnAsset_PassesThePresentFlagsAndReturnsTheResultingStateAsync()
{
var fixture = new TestFixture();
fixture.Interactions.ApplyAsync(UserId, AssetId, new InteractionChange(null, true, null), Arg.Any<CancellationToken>())
.Returns(new InteractionState(Starred: true, Liked: true, Hidden: false));
await using var app = await fixture.StartAsync();
using var client = app.Client;

using var response = await client.SendAsync(Patch(new { liked = true }, UserId), TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);
(await response.Content.ReadFromJsonAsync<AssetInteractionDto>(TestContext.Current.CancellationToken))
.Should().Be(new AssetInteractionDto(Starred: true, Liked: true, Hidden: false, DisplayWeight: 2.0));
}

[Fact]
public async Task PatchInteraction_EveryFlag_IsPassedAsSentAsync()
{
var fixture = new TestFixture();
fixture.Interactions.ApplyAsync(Arg.Any<Guid>(), Arg.Any<Guid>(), Arg.Any<InteractionChange>(), Arg.Any<CancellationToken>())
.Returns(new InteractionState(false, false, false));
await using var app = await fixture.StartAsync();
using var client = app.Client;

using var response = await client.SendAsync(
Patch(new { starred = true, liked = false, hidden = true }, UserId), TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);
await fixture.Interactions.Received(1).ApplyAsync(
UserId, AssetId, new InteractionChange(true, false, true), Arg.Any<CancellationToken>());
}

[Fact]
public async Task PatchInteraction_EmptyBody_PassesNoChangeAsync()
{
var fixture = new TestFixture();
fixture.Interactions.ApplyAsync(Arg.Any<Guid>(), Arg.Any<Guid>(), Arg.Any<InteractionChange>(), Arg.Any<CancellationToken>())
.Returns(new InteractionState(false, false, false));
await using var app = await fixture.StartAsync();
using var client = app.Client;

using var response = await client.SendAsync(Patch(new { }, UserId), TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);
(await response.Content.ReadFromJsonAsync<AssetInteractionDto>(TestContext.Current.CancellationToken))
.Should().Be(new AssetInteractionDto(false, false, false, 1.0));
await fixture.Interactions.Received(1).ApplyAsync(
UserId, AssetId, new InteractionChange(null, null, null), Arg.Any<CancellationToken>());
}

[Fact]
public async Task PatchInteraction_NoBodyAtAll_PassesNoChangeAsync()
{
var fixture = new TestFixture();
fixture.Interactions.ApplyAsync(Arg.Any<Guid>(), Arg.Any<Guid>(), Arg.Any<InteractionChange>(), Arg.Any<CancellationToken>())
.Returns(new InteractionState(false, true, false));
await using var app = await fixture.StartAsync();
using var client = app.Client;
using var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/assets/{AssetId}/interaction");
request.Headers.Add(AuthenticatedTestApp.UserHeader, UserId.ToString());

using var response = await client.SendAsync(request, TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);
(await response.Content.ReadFromJsonAsync<AssetInteractionDto>(TestContext.Current.CancellationToken))
.Should().Be(new AssetInteractionDto(false, true, false, 2.0));
await fixture.Interactions.Received(1).ApplyAsync(
UserId, AssetId, new InteractionChange(null, null, null), Arg.Any<CancellationToken>());
}

[Fact]
public async Task PatchInteraction_BodyNamingAnotherUser_WritesForTheCallerAsync()
{
var fixture = new TestFixture();
fixture.Interactions.ApplyAsync(Arg.Any<Guid>(), Arg.Any<Guid>(), Arg.Any<InteractionChange>(), Arg.Any<CancellationToken>())
.Returns(new InteractionState(true, false, false));
await using var app = await fixture.StartAsync();
using var client = app.Client;
var other = Guid.Parse("cccccccc-0000-0000-0000-000000000003");

using var response = await client.SendAsync(
Patch(new { userId = other, starred = true }, UserId), TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.OK);
await fixture.Interactions.Received(1).ApplyAsync(
UserId, AssetId, new InteractionChange(true, null, null), Arg.Any<CancellationToken>());
await fixture.Interactions.DidNotReceive().ApplyAsync(
other, Arg.Any<Guid>(), Arg.Any<InteractionChange>(), Arg.Any<CancellationToken>());
}

[Fact]
public async Task PatchInteraction_NotVisibleToTheCaller_ReturnsNotFoundAsync()
{
var fixture = new TestFixture();
await using var app = await fixture.StartAsync();
using var client = app.Client;

using var response = await client.SendAsync(Patch(new { starred = true }, UserId), TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}

[Fact]
public async Task PatchInteraction_Anonymous_ReturnsUnauthorizedWithoutAWriteAsync()
{
var fixture = new TestFixture();
await using var app = await fixture.StartAsync();
using var client = app.Client;

using var response = await client.SendAsync(Patch(new { starred = true }), TestContext.Current.CancellationToken);

response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
await fixture.Interactions.DidNotReceive().ApplyAsync(
Arg.Any<Guid>(), Arg.Any<Guid>(), Arg.Any<InteractionChange>(), Arg.Any<CancellationToken>());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ private static ServiceCollection AuthServices(IConfiguration? configuration = nu
{ typeof(IUserStorageConfigRepository), typeof(EfUserStorageConfigRepository), ServiceLifetime.Scoped },
{ typeof(IMediaAssetReadRepository), typeof(EfMediaAssetReadRepository), ServiceLifetime.Scoped },
{ typeof(IAssetViewReader), typeof(EfAssetViewReader), ServiceLifetime.Scoped },
{ typeof(IAssetInteractionWriter), typeof(EfAssetInteractionWriter), ServiceLifetime.Scoped },
{ typeof(IProxyStore), typeof(ProxyStore), ServiceLifetime.Singleton },
{ typeof(IOriginalStore), typeof(OriginalStore), ServiceLifetime.Singleton },
{ typeof(IRegistrationValidator), typeof(RegistrationValidator), ServiceLifetime.Scoped },
Expand Down
10 changes: 6 additions & 4 deletions src/Anichron.API.Tests.Unit/Infrastructure/RouteTableTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,12 @@ public void UserRoutes_AreTaggedUsers()
=> Routes.Where(r => r.Pattern.StartsWith("/api/v1/users", StringComparison.Ordinal))
.Should().OnlyContain(r => r.Tags.SequenceEqual(new[] { "Users" }));

[Fact]
public void ProxyRoute_RequiresAnAuthenticatedUserButNotAdmin()
[Theory]
[InlineData("GET", "/api/v1/assets/{assetId:guid}/proxies/{type}")]
[InlineData("PATCH", "/api/v1/assets/{assetId:guid}/interaction")]
public void AssetRoutes_RequireAnAuthenticatedUserButNotAdmin(string method, string pattern)
{
var route = Find("GET", "/api/v1/assets/{assetId:guid}/proxies/{type}");
var route = Find(method, pattern);

route.Anonymous.Should().BeFalse();
route.Authenticated.Should().BeTrue();
Expand Down Expand Up @@ -143,5 +145,5 @@ public void Healthz_IsAnonymous()

[Fact]
public void EveryMappedRoute_IsCoveredByTheTestsAbove()
=> Routes.Should().HaveCount(21);
=> Routes.Should().HaveCount(22);
}
5 changes: 4 additions & 1 deletion src/Anichron.API/Endpoints/AssetDtoMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ internal static AssetDto ToDto(AssetView view)
view is { PairedMovHasVideo: true, PairedMovId: { } movId }
? ProxyKinds.UrlOf(movId, ProxyType.WebVideo)
: null,
new AssetInteractionDto(view.IsStarred, view.IsLiked, Hidden: false, FlashbackWeight.For(view.IsLiked)));
ToDto(new InteractionState(view.IsStarred, view.IsLiked, Hidden: false)));
}

internal static AssetInteractionDto ToDto(InteractionState state)
=> new(state.Starred, state.Liked, state.Hidden, FlashbackWeight.For(state.Liked));

private static string PublishedMediaType(MediaType type)
=> type switch
{
Expand Down
20 changes: 20 additions & 0 deletions src/Anichron.API/Endpoints/MediaEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

namespace Anichron.API.Endpoints;

public sealed record PatchInteractionRequest(bool? Starred, bool? Liked, bool? Hidden);

public static class MediaEndpoints
{
internal const string ProxyCacheControl = "private, max-age=31536000, immutable";
Expand All @@ -16,6 +18,8 @@ public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder
var group = app.MapGroup(ApiPaths.Assets.Group).WithTags("Media");
group.MapGet(ApiPaths.Assets.Proxy, GetProxyAsync)
.RequireAuthorization();
group.MapPatch(ApiPaths.Assets.Interaction, PatchInteractionAsync)
.RequireAuthorization();
return app;
}

Expand Down Expand Up @@ -48,4 +52,20 @@ internal static async Task<IResult> GetProxyAsync(
entityTag: new EntityTagHeaderValue($"\"{proxy.ContentHash}-{kind.Type}\""),
enableRangeProcessing: true);
}

internal static async Task<IResult> PatchInteractionAsync(
Guid assetId,
PatchInteractionRequest? request,
ClaimsPrincipal user,
[FromServices] IAssetInteractionWriter interactions,
CancellationToken ct)
{
if (!Guid.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return Results.Unauthorized();

var change = new InteractionChange(request?.Starred, request?.Liked, request?.Hidden);
return await interactions.ApplyAsync(userId, assetId, change, ct) is { } state
? Results.Ok(AssetDtoMapper.ToDto(state))
: Results.NotFound();
}
}
1 change: 1 addition & 0 deletions src/Anichron.API/Infrastructure/ApiPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ internal static class Assets
{
internal const string Group = "assets";
internal const string Proxy = "{assetId:guid}/proxies/{type}";
internal const string Interaction = "{assetId:guid}/interaction";

internal static string ProxyUrl(Guid assetId, string type)
=> $"{Group}/{assetId}/proxies/{type}";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ public IServiceCollection AddAuthServices(IConfiguration configuration)
services.AddScoped<IUserStorageConfigRepository, EfUserStorageConfigRepository>();
services.AddScoped<IMediaAssetReadRepository, EfMediaAssetReadRepository>();
services.AddScoped<IAssetViewReader, EfAssetViewReader>();
services.AddScoped<IAssetInteractionWriter, EfAssetInteractionWriter>();
services.AddSingleton<IProxyStore, ProxyStore>();
services.AddSingleton<IOriginalStore, OriginalStore>();
// AnichronDbContext is already scoped via AddDbContext; reuse the same instance for IUnitOfWork
Expand Down
11 changes: 11 additions & 0 deletions src/Anichron.Core.Tests.Unit/Data/ModelInvariantsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ public void ContentHash_IsUniqueOnlyPerStorageConfigAndOnlyAmongActiveAssets()
index.GetDatabaseName().Should().Be(MediaAssetIndexNames.ActiveContentHashUnique);
}

[Fact]
public void Interactions_AreUniquePerUserAndAssetUnderTheNameTheWriterRetriesOn()
{
using var context = AnichronDbContexts.NpgsqlModel();
var interaction = context.Model.FindEntityType(typeof(AssetInteraction))!;

var index = interaction.GetIndexes().Where(index => index.IsUnique).Should().ContainSingle().Which;
index.Properties.Select(p => p.Name).Should().Equal(nameof(AssetInteraction.UserId), nameof(AssetInteraction.AssetId));
index.GetDatabaseName().Should().Be(InteractionIndexNames.OnePerUserAndAsset);
}

[Fact]
public void QueryFilters_AreSoftDeleteOnly()
{
Expand Down
Loading
Loading