diff --git a/.claude/launch.json b/.claude/launch.json
index 31fc5571..8ce1121e 100644
--- a/.claude/launch.json
+++ b/.claude/launch.json
@@ -22,6 +22,25 @@
"dev:studio"
],
"port": 5174
+ },
+ {
+ "name": "mock-backend",
+ "runtimeExecutable": "dotnet",
+ "runtimeArgs": [
+ "run",
+ "--project",
+ "dotnet/src/Mediatracker.Api.Mock"
+ ],
+ "port": 13001
+ },
+ {
+ "name": "frontend-mock",
+ "runtimeExecutable": "sh",
+ "runtimeArgs": [
+ "-c",
+ "cd frontend/apps/mediatracker && MEDIATRACKER_API_PROXY=http://localhost:13001 npx vite --port 5175 --strictPort"
+ ],
+ "port": 5175
}
]
}
diff --git a/CLAUDE.md b/CLAUDE.md
index 9deb6181..e3a01231 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -237,6 +237,13 @@ Metadata (movie, show, season) is cached in Postgres per source, language and id
**Load tests with many users (`LoadScenarioTests`, `Load/`).** The same project simulates *masses of users at the same time* against the in-process backend (real services, Postgres by Testcontainers, fake PDS and metadata sources; **no HTTP layer**: the controllers, JSON and authentication are not in the measurement, the services and their database/index/cache work are). **[NBomber](https://nbomber.com) runs the simulation and writes the reports** (`LoadRunner`: the users are the copies of a scenario, one scenario per kind of user, every action a measured step with percentiles; HTML, markdown and CSV per test into `dotnet/perf-reports/`, `MEDIATRACKER_PERF_REPORT_DIR`, gitignored; a summary also goes to `dotnet/perf-report.md`, `MEDIATRACKER_PERF_REPORT`; both are appended to, so delete them for a clean report). **NBomber is free for personal use only, an organization needs a license** (nbomber.com/pricing): check that before this project is used by one. Every user (`VirtualUser`) has an account of their own: a `FakePds` registered in `FakePdsRegistry` (the `FakeRepoClientFactory` picks the repo by the credentials' DID) and a `Session`. **Libraries are drawn by popularity from a catalogue much larger than the reference account** (`SyntheticWorld.Extend`: `MEDIATRACKER_PERF_CATALOGUE_FACTOR`, default 8 times the reference's titles, each new title modelled on one of the reference's seasons/episodes/watch statistics; `Popularity`: Zipf, a few titles are in many libraries, the long tail in few; the same distribution picks the titles users open or track), so users overlap but do not all hold the same ~830 titles. Kinds of user (`UserProfile`, mixed 65/25/7/3 by `UserProfile.Mix`): `light` 5 %, `regular` 25 %, `heavy` 100 % (the reference: 417 shows, 411 movies, ~32,000 records), `giant` 200 % (~65,000 records). Three tests per user count: **login storm** (all users log in at the same moment with an empty index and the real PDS latency, the metadata of the titles cached: `login+home` per kind of user = what they wait for), **steady load** (libraries and metadata warm, then every user loops for a while: pick an action by weight, run it, pause) and **first login ever** (`MEDIATRACKER_PERF_COLD_USERS`, default `10,30`: the metadata cache empty too, so every title has to be asked of the source). `UserActions` is the catalogue of what a non-admin user can do (reading: home, library pages/search/next page, calendar, movie and show detail pages with the parallel requests the frontend makes, season, episode, title search, search as library page, discover, recommendations page, lists; writing: track/untrack, watch, delete watch, rate/unrate for movies, shows and episodes, mark a season, move to another entry, delete an entry, lists, export); **a new user-facing feature = an action there**. Each request of an action gets a scope of its own (`ActionContext.RunAsync`), requests the browser makes together are made together (`ParallelAsync`). **The fake metadata source limits how often it is asked** (`FakeSourceLatency`: `MEDIATRACKER_PERF_SOURCE_RPS`, default 40 calls per second like TMDB's documented "somewhere in the 40 requests per second range", which can change without notice; over it a call fails with TMDbLib's `RequestLimitExceededException`; 0 = no limit; only while calls take time, so the warm-ups are not limited) and **goes through the application's own `TmdbRateLimit`** with its limiter (`MEDIATRACKER_PERF_APP_RPS`, default 30 like `TmdbConfiguration:MaxRequestsPerSecond`), so the load tests see what the real client does: wait, or `RateLimited`; only TMDB is simulated, so Jikan's own throttle (3/s, 55/min) is not. A request that fails as `RateLimited`/429 is reported as `rate-limited`, **any other failure fails the test**. The database pool is as large as a deployment's (`MEDIATRACKER_PERF_MAX_POOL`, default 80 = `MediatrackerEnvironmentMapper`'s `MaxPoolSize`; Npgsql's own default of 100 equals what PostgreSQL allows, so a burst failed with "too many clients" instead of waiting). **Findings so far (October 2026), fixed:** a 429 from TMDB was handled nowhere (500 for the user) and a detail page of an uncached title makes ~6 source calls at once, so a few new titles per second passed 40 calls/s - now the requests are limited and a refusal is reported as 429 (see "Rate limits of TMDB and TVDB"); a first login with an empty cache re-asked refused titles again and again - now a failed key cools down (see "Metadata cache and loading"). Still slow, by design of the PDS: a giant user (65,000 records) waits ~75 s for the first read from the PDS, a heavy one ~40 s. **The first read of an account goes through a simulated Tap** (`FakeTapBackfill`: the repo in one go at `MEDIATRACKER_PERF_TAP_RPS` records a second, default 2,000 as measured against a real Tap, `MEDIATRACKER_PERF_TAP_PARALLEL` accounts at a time, assumed 5, the records handed to the real `RecordIndexTapHandler`, so the index work is real; `MEDIATRACKER_PERF_TAP=0` pages through the PDS as without a Tap). Not covered: the HTTP layer, the restore job, the calendar feed (needs the identity database), the image proxy, Popfeed and everything admin. Configuration by environment: `MEDIATRACKER_PERF_USERS` (default `10,50,100`), `_DURATION_S` (30), `_THINK_MS` (average pause, 1000: shorter than a real user's to pack more load in). Run: `MEDIATRACKER_PERF=1 dotnet test dotnet/tests/Mediatracker.Performance.Tests --filter LoadScenarioTests`.
+## Mock backend (the app without any account or key)
+
+`dotnet/src/Mediatracker.Api.Mock` is the **real API** (`ApiHost.RunAsync`, which `Program.cs` of the API runs too; `ApiHostCustomization` has two hooks, `BeforeTracking` - the repo client factory must be replaced before `AddTrackingCore` decorates it - and `AfterRegistration`) with everything that reaches outside replaced by the fakes of the performance tests, which live in `dotnet/src/Mediatracker.Testing` (`Fakes/`, `Shape/`, `account-shape.json`; shared by the perf tests and the mock, never referenced by the real API or its Docker build): the PDS (`FakePds`), Tap (`FakeTapBackfill`), the metadata source (`FakeMovie/TvShowMetadataProvider` as TMDB with the `pages` capability, `MockMetadataSourceHealthCheck`), the title resolver, feeds and the image deduplicator. **The login needs no PDS**: `MockIdentityResolutionService` maps any handle to the one account `did:plc:mockuser` (an admin) and `MockOAuthLoginFlow` makes the "authorization page" the callback itself, so the real `AuthController` does the rest. The account is filled from `account-shape.json` (417 shows, 411 movies, ~32,000 records; titles like "Show 176 rcwueqwi", posters at `image.example`, so images fail to load) plus a catalogue twice that size (`MockWorld`). Its own database `mediatracker_mock` is created on the PostgreSQL of the compose file (`MockDatabase`; the real data is never touched), AniDB/AnimeThemes/Popfeed/the feed directory are off, the environment is `Mock` (`appsettings.Mock.json` next to the API's `appsettings.json`, which is linked into the output; the content root is the output folder). **It listens on `:13001`, so it runs next to the real API on `:13000`**; the Vite dev server proxies `/api` to `MEDIATRACKER_API_PROXY` (default `:13000`).
+
+- **Start:** PostgreSQL (`docker compose up -d postgres` in `dotnet/src`), `dotnet run --project dotnet/src/Mediatracker.Api.Mock`, then the frontend with `MEDIATRACKER_API_PROXY=http://localhost:13001 npm run dev` and log in with any handle. The first read of the account takes about 10 s. Rider: run configurations `Mock Postgres` (only the `postgres` service) and `Mock Backend` (the `mock` launch profile); WebStorm: `Start Mediatracker (Mock)`. For Claude's preview: `.claude/launch.json` `mock-backend` and `frontend-mock` (frontend on `:5175`, an allowed origin of the mock; open it as `http://127.0.0.1:5175`).
+- **Not covered:** admin pages that look at real services show them as down or off, one fake source only (TMDB), no images. **Planned:** a small profile with edge-case titles (very long, RTL, CJK, emoji, HTML, empty fields), fault injection (429/502/timeout) and a choice of account size.
+
## Record index
Reading a user's records straight from the PDS is slow: `listRecords` returns 100 records per page and every page needs the cursor of the previous one, so a library with ~15,000 episode watches took over a minute per request. Instead the backend keeps a copy of the user's `dev.erican.mediatracker.*` records in Postgres (`tracking_index` schema, `ConnectionStrings:TrackingIndex`, code in `Mediatracker.Tracking/RecordIndex/`).
diff --git a/dotnet/.run/Mock Backend.run.xml b/dotnet/.run/Mock Backend.run.xml
new file mode 100644
index 00000000..05915792
--- /dev/null
+++ b/dotnet/.run/Mock Backend.run.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/.run/Mock Postgres.run.xml b/dotnet/.run/Mock Postgres.run.xml
new file mode 100644
index 00000000..8fe8d171
--- /dev/null
+++ b/dotnet/.run/Mock Postgres.run.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/Mediatracker.slnx b/dotnet/Mediatracker.slnx
index d32af03a..481c82bb 100644
--- a/dotnet/Mediatracker.slnx
+++ b/dotnet/Mediatracker.slnx
@@ -23,6 +23,8 @@
+
+
diff --git a/dotnet/src/Mediatracker.Api.Mock/Mediatracker.Api.Mock.csproj b/dotnet/src/Mediatracker.Api.Mock/Mediatracker.Api.Mock.csproj
new file mode 100644
index 00000000..90b790fe
--- /dev/null
+++ b/dotnet/src/Mediatracker.Api.Mock/Mediatracker.Api.Mock.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+ false
+ $(NoWarn);CS1591
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Mediatracker.Api.Mock/MockDatabase.cs b/dotnet/src/Mediatracker.Api.Mock/MockDatabase.cs
new file mode 100644
index 00000000..e89e88d1
--- /dev/null
+++ b/dotnet/src/Mediatracker.Api.Mock/MockDatabase.cs
@@ -0,0 +1,51 @@
+using Npgsql;
+
+namespace Mediatracker.Api.Mock;
+
+///
+/// Makes sure the mock's own database exists on the PostgreSQL server (the one of the compose file), so the mock
+/// never touches the data of the real development database.
+///
+public static class MockDatabase
+{
+ ///
+ /// Creates the database of if it is missing.
+ ///
+ public static async Task EnsureExistsAsync(string connectionString)
+ {
+ var target = new NpgsqlConnectionStringBuilder(connectionString);
+ var name = target.Database!;
+
+ target.Database = "postgres";
+
+ try
+ {
+ await using var connection = new NpgsqlConnection(target.ToString());
+
+ await connection.OpenAsync();
+
+ await using var exists = connection.CreateCommand();
+
+ exists.CommandText = "SELECT 1 FROM pg_database WHERE datname = @name";
+ exists.Parameters.AddWithValue("name", name);
+
+ if (await exists.ExecuteScalarAsync() is not null)
+ {
+ return;
+ }
+
+ await using var create = connection.CreateCommand();
+
+ create.CommandText = $"CREATE DATABASE \"{name}\"";
+
+ await create.ExecuteNonQueryAsync();
+ }
+ catch (NpgsqlException exception)
+ {
+ throw new InvalidOperationException(
+ $"The mock backend needs PostgreSQL ({target.Host}:{target.Port}). Start it with "
+ + "'docker compose up -d postgres' in dotnet/src.", exception
+ );
+ }
+ }
+}
diff --git a/dotnet/src/Mediatracker.Api.Mock/MockIdentityResolutionService.cs b/dotnet/src/Mediatracker.Api.Mock/MockIdentityResolutionService.cs
new file mode 100644
index 00000000..9381a033
--- /dev/null
+++ b/dotnet/src/Mediatracker.Api.Mock/MockIdentityResolutionService.cs
@@ -0,0 +1,24 @@
+using Mediatracker.AtProto.Resolution;
+
+namespace Mediatracker.Api.Mock;
+
+///
+/// Resolves every handle to the mock account, without asking anyone.
+///
+public class MockIdentityResolutionService : IIdentityResolutionService
+{
+ ///
+ public Task ResolveHandleAsync(string handle, CancellationToken cancellationToken = default)
+ => Task.FromResult(
+ new ResolvedLoginTarget
+ {
+ Did = MockWorld.Did,
+ Handle = string.IsNullOrWhiteSpace(handle) ? "mock.test" : handle.Trim(),
+ PdsUrl = "https://pds.mock.test",
+ Issuer = "https://pds.mock.test",
+ PushedAuthorizationRequestEndpoint = "https://pds.mock.test/par",
+ AuthorizationEndpoint = "https://pds.mock.test/authorize",
+ TokenEndpoint = "https://pds.mock.test/token",
+ }
+ );
+}
diff --git a/dotnet/src/Mediatracker.Api.Mock/MockMetadataSourceHealthCheck.cs b/dotnet/src/Mediatracker.Api.Mock/MockMetadataSourceHealthCheck.cs
new file mode 100644
index 00000000..f6f13e5d
--- /dev/null
+++ b/dotnet/src/Mediatracker.Api.Mock/MockMetadataSourceHealthCheck.cs
@@ -0,0 +1,22 @@
+using Mediatracker.Metadata;
+using Mediatracker.Metadata.Sources;
+
+namespace Mediatracker.Api.Mock;
+
+///
+/// The fake TMDB is always there.
+///
+public class MockMetadataSourceHealthCheck : IMetadataSourceHealthCheck
+{
+ ///
+ public MetadataSourceDto Info { get; } = new()
+ {
+ Source = MetadataSource.Tmdb,
+ DisplayName = "TMDB",
+ MovieUrlTemplate = "https://www.themoviedb.org/movie/{id}",
+ TvShowUrlTemplate = "https://www.themoviedb.org/tv/{id}",
+ };
+
+ ///
+ public Task CheckAsync(CancellationToken cancellationToken = default) => Task.FromResult(true);
+}
diff --git a/dotnet/src/Mediatracker.Api.Mock/MockOAuthLoginFlow.cs b/dotnet/src/Mediatracker.Api.Mock/MockOAuthLoginFlow.cs
new file mode 100644
index 00000000..9d2468f6
--- /dev/null
+++ b/dotnet/src/Mediatracker.Api.Mock/MockOAuthLoginFlow.cs
@@ -0,0 +1,90 @@
+using System.Security.Cryptography;
+using Mediatracker.AtProto.OAuth;
+using Mediatracker.AtProto.Resolution;
+
+namespace Mediatracker.Api.Mock;
+
+///
+/// A login without an authorization server: the "authorization page" is the callback itself, so signing in is one
+/// redirect and the real AuthController does the rest (session, bearer token, warming the index).
+///
+public class MockOAuthLoginFlow : IOAuthLoginFlow
+{
+ private const string Client = "https://mock.test/client-metadata.json";
+
+ ///
+ public Task StartAsync(
+ ResolvedLoginTarget target,
+ string redirectUri,
+ string returnTo,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
+ var now = DateTimeOffset.UtcNow;
+
+ var pending = new PendingAuthorization
+ {
+ State = state,
+ ClientId = Client,
+ RedirectUri = redirectUri,
+ CodeVerifier = "mock",
+ DpopKeyJwkJson = "{}",
+ ExpectedDid = target.Did,
+ Handle = target.Handle,
+ Issuer = target.Issuer,
+ TokenEndpoint = target.TokenEndpoint,
+ PdsUrl = target.PdsUrl,
+ ReturnTo = returnTo,
+ CreatedAt = now,
+ ExpiresAt = now.AddMinutes(10),
+ };
+
+ var separator = redirectUri.Contains('?') ? '&' : '?';
+
+ return Task.FromResult(
+ new StartedLogin
+ {
+ PendingAuthorization = pending,
+ AuthorizationRedirectUrl
+ = $"{redirectUri}{separator}code=mock&state={state}&iss={Uri.EscapeDataString(target.Issuer)}",
+ }
+ );
+ }
+
+ ///
+ public Task CompleteAsync(
+ PendingAuthorization pendingAuthorization,
+ string code,
+ string iss,
+ CancellationToken cancellationToken = default
+ )
+ => Task.FromResult(
+ new CompletedLogin
+ {
+ Did = pendingAuthorization.ExpectedDid,
+ Handle = pendingAuthorization.Handle,
+ PdsUrl = pendingAuthorization.PdsUrl,
+ Issuer = pendingAuthorization.Issuer,
+ TokenEndpoint = pendingAuthorization.TokenEndpoint,
+ ClientId = pendingAuthorization.ClientId,
+ DpopKeyJwkJson = "{}",
+ AccessToken = "mock",
+ RefreshToken = "mock",
+ AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddYears(10),
+ }
+ );
+
+ ///
+ public Task RefreshAsync(
+ string tokenEndpoint,
+ string issuer,
+ string clientId,
+ string? clientKeyId,
+ string expectedDid,
+ string refreshToken,
+ string dpopKeyJwkJson,
+ CancellationToken cancellationToken = default
+ )
+ => throw new NotSupportedException("The mock login never expires.");
+}
diff --git a/dotnet/src/Mediatracker.Api.Mock/MockServices.cs b/dotnet/src/Mediatracker.Api.Mock/MockServices.cs
new file mode 100644
index 00000000..a3ff85b1
--- /dev/null
+++ b/dotnet/src/Mediatracker.Api.Mock/MockServices.cs
@@ -0,0 +1,78 @@
+using Mediatracker.AtProto.OAuth;
+using Mediatracker.AtProto.Repo;
+using Mediatracker.AtProto.Resolution;
+using Mediatracker.AtProto.Tap;
+using Mediatracker.Metadata.IdMappings;
+using Mediatracker.Metadata.Images;
+using Mediatracker.Metadata.Movies;
+using Mediatracker.Metadata.Sources;
+using Mediatracker.Metadata.TitleResolution;
+using Mediatracker.Metadata.TvShows;
+using Mediatracker.Testing.Fakes;
+using Mediatracker.Tracking.Feeds;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Mediatracker.Api.Mock;
+
+///
+/// What the mock backend replaces in the API: everything that reaches outside (the PDS, Tap, the metadata
+/// sources, the authorization server, other services' feeds) by the fakes of the performance tests.
+///
+public static class MockServices
+{
+ ///
+ /// The customization that puts in place of the outside.
+ ///
+ public static ApiHostCustomization Customize(MockWorld world)
+ => new()
+ {
+ // Before the tracking services decorate it with the record index.
+ BeforeTracking = services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(world.Pdses);
+ services.AddScoped(_ => new FakeRepoClientFactory(world.Pdses));
+ },
+ AfterRegistration = builder => Replace(builder.Services, world),
+ };
+
+ private static void Replace(IServiceCollection services, MockWorld world)
+ {
+ // The login: any handle is the mock account and the "authorization page" is the callback.
+ services.RemoveAll();
+ services.AddSingleton();
+ services.RemoveAll();
+ services.AddSingleton();
+ services.RemoveAll();
+ services.AddSingleton();
+
+ // The first read of an account goes through the fake Tap, which hands the records to the real handler.
+ services.RemoveAll();
+ services.AddSingleton();
+
+ // The one metadata source: the catalogue, answering after a short latency.
+ services.AddSingleton(world.Catalogue);
+ services.AddSingleton(world.Calls);
+ services.AddSingleton(world.SourceLatency);
+ services.RemoveAll();
+ services.RemoveAll();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.RemoveAll();
+ services.AddSingleton();
+
+ services.AddSingleton();
+ services.RemoveAll();
+ services.RemoveAll();
+ services.AddSingleton(provider => provider.GetRequiredService());
+ services.AddSingleton(provider => provider.GetRequiredService());
+
+ // Feeds of other services, and images (the fake posters are not fetched from anywhere).
+ services.RemoveAll();
+ services.RemoveAll();
+ services.AddScoped();
+ services.AddScoped();
+ services.RemoveAll();
+ services.AddSingleton();
+ }
+}
diff --git a/dotnet/src/Mediatracker.Api.Mock/MockWorld.cs b/dotnet/src/Mediatracker.Api.Mock/MockWorld.cs
new file mode 100644
index 00000000..363091e1
--- /dev/null
+++ b/dotnet/src/Mediatracker.Api.Mock/MockWorld.cs
@@ -0,0 +1,58 @@
+using Mediatracker.Testing.Fakes;
+using Mediatracker.Testing.Shape;
+
+namespace Mediatracker.Api.Mock;
+
+///
+/// The data of the mock backend: the one account that can sign in (any handle is that account), its repo and the
+/// catalogue of titles the fake metadata source knows.
+///
+public class MockWorld
+{
+ ///
+ /// The DID every login signs in as.
+ ///
+ public const string Did = "did:plc:mockuser";
+
+ ///
+ /// Creates the world: an account of the size of a real one (from the shape file) and a larger catalogue.
+ ///
+ public MockWorld()
+ {
+ Pds = new FakePds { Did = Did, PageLatency = TimeSpan.Zero, };
+ Pdses.Add(Pds);
+
+ SourceLatency.PerCall = TimeSpan.FromMilliseconds(15);
+
+ var shape = AccountShape.Load();
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+
+ SyntheticAccount.Build(shape, Pds, Catalogue, today);
+ SyntheticWorld.Extend(Catalogue, shape, 2, today);
+ }
+
+ ///
+ /// The repo of the account.
+ ///
+ public FakePds Pds { get; }
+
+ ///
+ /// The repos by DID.
+ ///
+ public FakePdsRegistry Pdses { get; } = new();
+
+ ///
+ /// The titles the fake source knows.
+ ///
+ public SyntheticCatalogue Catalogue { get; } = new();
+
+ ///
+ /// What the fake source was asked.
+ ///
+ public ProviderCallLog Calls { get; } = new();
+
+ ///
+ /// How the fake source answers.
+ ///
+ public FakeSourceLatency SourceLatency { get; } = new();
+}
diff --git a/dotnet/src/Mediatracker.Api.Mock/Program.cs b/dotnet/src/Mediatracker.Api.Mock/Program.cs
new file mode 100644
index 00000000..bd3696d5
--- /dev/null
+++ b/dotnet/src/Mediatracker.Api.Mock/Program.cs
@@ -0,0 +1,18 @@
+using Mediatracker.Api;
+using Mediatracker.Api.Mock;
+using Microsoft.Extensions.Configuration;
+
+// The content root is the output folder, where the API's appsettings.json is linked next to the mock's own.
+var options = new WebApplicationOptions
+{
+ Args = args, EnvironmentName = "Mock", ContentRootPath = AppContext.BaseDirectory,
+};
+
+var configuration = new ConfigurationBuilder().SetBasePath(AppContext.BaseDirectory)
+ .AddJsonFile("appsettings.Mock.json")
+ .AddEnvironmentVariables()
+ .Build();
+
+await MockDatabase.EnsureExistsAsync(configuration.GetConnectionString("MetadataCache")!);
+
+await ApiHost.RunAsync(options, MockServices.Customize(new MockWorld()));
diff --git a/dotnet/src/Mediatracker.Api.Mock/Properties/launchSettings.json b/dotnet/src/Mediatracker.Api.Mock/Properties/launchSettings.json
new file mode 100644
index 00000000..eeba9105
--- /dev/null
+++ b/dotnet/src/Mediatracker.Api.Mock/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "mock": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:13001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Mock"
+ }
+ }
+ }
+}
diff --git a/dotnet/src/Mediatracker.Api.Mock/appsettings.Mock.json b/dotnet/src/Mediatracker.Api.Mock/appsettings.Mock.json
new file mode 100644
index 00000000..44e11058
--- /dev/null
+++ b/dotnet/src/Mediatracker.Api.Mock/appsettings.Mock.json
@@ -0,0 +1,46 @@
+{
+ "ConnectionStrings": {
+ "MetadataCache": "Host=localhost;Port=5432;Database=mediatracker_mock;Username=mediatracker;Password=mediatracker",
+ "Identity": "Host=localhost;Port=5432;Database=mediatracker_mock;Username=mediatracker;Password=mediatracker",
+ "TrackingIndex": "Host=localhost;Port=5432;Database=mediatracker_mock;Username=mediatracker;Password=mediatracker",
+ "Community": "Host=localhost;Port=5432;Database=mediatracker_mock;Username=mediatracker;Password=mediatracker"
+ },
+ "Admin": {
+ "InitialDids": [
+ "did:plc:mockuser"
+ ]
+ },
+ "AniDb": {
+ "Enabled": "false"
+ },
+ "AnimeThemes": {
+ "Enabled": "false"
+ },
+ "Popfeed": {
+ "Enabled": "false"
+ },
+ "FeedDirectory": {
+ "Enabled": "false"
+ },
+ "Jobs": {
+ "IdMappings": {
+ "Enabled": "false"
+ }
+ },
+ "AtProtoOAuth": {
+ "AllowedFrontendOrigins": [
+ "http://localhost:5173",
+ "http://localhost:5174",
+ "http://127.0.0.1:5173",
+ "http://127.0.0.1:5174",
+ "http://localhost:5175",
+ "http://127.0.0.1:5175"
+ ]
+ },
+ "Feeds": {
+ "AllowPrivateAddresses": true
+ },
+ "AtProtoResolution": {
+ "AllowInsecureLocalDidWeb": true
+ }
+}
\ No newline at end of file
diff --git a/dotnet/src/Mediatracker.Api/ApiHost.cs b/dotnet/src/Mediatracker.Api/ApiHost.cs
new file mode 100644
index 00000000..e2d612cb
--- /dev/null
+++ b/dotnet/src/Mediatracker.Api/ApiHost.cs
@@ -0,0 +1,268 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Mediatracker.Administration;
+using Mediatracker.Administration.AspNetCore.AppVersion;
+using Mediatracker.Administration.AspNetCore.Authentication;
+using Mediatracker.Administration.AspNetCore.Controllers;
+using Mediatracker.Administration.AspNetCore.Http;
+using Mediatracker.Administration.AspNetCore.Logging;
+using Mediatracker.Administration.Logs;
+using Mediatracker.Administration.Settings;
+using Mediatracker.Api.Configuration;
+using Mediatracker.Api.Controllers.Auth;
+using Mediatracker.Api.Controllers.Sources;
+using Mediatracker.Api.Localization;
+using Mediatracker.Api.Logging;
+using Mediatracker.Api.Middleware;
+using Mediatracker.Api.Status;
+using Mediatracker.Api.StudioIntegration;
+using Mediatracker.AtProto;
+using Mediatracker.AtProto.OAuth;
+using Mediatracker.AtProto.Resolution;
+using Mediatracker.AtProto.ServiceAuth;
+using Mediatracker.Community;
+using Mediatracker.Identity;
+using Mediatracker.Identity.Admins;
+using Mediatracker.Identity.Sessions;
+using Mediatracker.Metadata;
+using Mediatracker.Metadata.AniDb;
+using Mediatracker.Metadata.AniDb.Storage;
+using Mediatracker.Metadata.AnimeThemes;
+using Mediatracker.Metadata.Caching;
+using Mediatracker.Metadata.Mal;
+using Mediatracker.Metadata.Popfeed;
+using Mediatracker.Metadata.Tmdb;
+using Mediatracker.Metadata.Tvdb;
+using Mediatracker.Tap;
+using Mediatracker.Tracking;
+using Mediatracker.Tracking.RecordIndex;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.EntityFrameworkCore;
+using Serilog;
+using Serilog.Core;
+using Serilog.Events;
+
+namespace Mediatracker.Api;
+
+///
+/// The API as a host: Program runs it as is, the mock backend runs it with its own services.
+///
+public static class ApiHost
+{
+ ///
+ /// Builds and runs the API; may change the host
+ /// at the places the customization names.
+ ///
+ public static async Task RunAsync(WebApplicationOptions options, ApiHostCustomization? customization = null)
+ {
+ var builder = WebApplication.CreateBuilder(options);
+
+ // The console's log is configured in appsettings; the sink also keeps it in the database for the administration. The
+ // level the logger lets through is a switch: the configuration's default, or lower if the log's own setting asks for
+ // more (debug entries in the log), which the settings service moves at once (below).
+ var configuredLevel = Enum.TryParse(
+ builder.Configuration["Serilog:MinimumLevel:Default"], true, out var parsedLevel
+ )
+ ? parsedLevel
+ : LogEventLevel.Information;
+
+ var levelSwitch = new LoggingLevelSwitch(configuredLevel);
+
+ builder.Host.UseSerilog((context, services, configuration) => configuration.ReadFrom
+ .Configuration(context.Configuration)
+ .MinimumLevel.ControlledBy(levelSwitch)
+ .WriteTo.Sink(services.GetRequiredService())
+ );
+
+ builder.Services.AddSensitiveDataLoggingSetting();
+
+ builder.Configuration.AddTvdbClient();
+ builder.Configuration.AddMediatrackerEnvironment();
+
+ // A login can make tokens for the Feed Studio only if it asked for the permission, and it asks for nothing the app
+ // doesn't use: the scope is added only where a Studio is set up (read at the start: a change of the DID in the
+ // administration applies to the scope after a restart).
+ if (!string.IsNullOrWhiteSpace(builder.Configuration["StudioIntegration:ServiceDid"]))
+ {
+ builder.Configuration.AddInMemoryCollection(
+ new Dictionary
+ {
+ ["AtProtoOAuth:AdditionalScope"]
+ = $"{builder.Configuration["AtProtoOAuth:AdditionalScope"]} {ServiceAuthMethods.StudioLoginScope}"
+ .Trim(),
+ }
+ );
+ }
+
+ builder.Services.AddScoped();
+
+ builder.Services.AddControllers(options => options.Filters.Add())
+ .AddJsonOptions(options
+ => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase))
+ );
+
+ builder.Services.AddOpenApi();
+
+ builder.Services.AddProblemDetails();
+ builder.Services.AddTransient();
+ builder.Services.AddForwardedPrefix();
+
+ // Behind a TLS-terminating reverse proxy the request arrives as plain http; trust its X-Forwarded-* headers so the
+ // scheme and host are the ones the client used. The proxy's address isn't known up front (a container network).
+ builder.Services.Configure(options =>
+ {
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
+ options.KnownIPNetworks.Clear();
+ options.KnownProxies.Clear();
+ }
+ );
+
+ builder.Services.AddCors(options
+ => options.AddDefaultPolicy(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())
+ );
+
+ builder.Services.AddMetadataCore(
+ builder.Configuration.GetConnectionString("MetadataCache")
+ ?? throw new InvalidOperationException("Missing ConnectionStrings:MetadataCache configuration.")
+ );
+
+ builder.Services.AddExternalLinks();
+
+ // The last source of the configuration: what an admin sets for the sources wins over files and environment.
+ builder.Services.AddSourceSettings(builder.Configuration);
+
+ builder.Services.AddLogStore();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+
+ builder.Services.AddHostedService();
+
+ builder.Services.AddImageProxy(builder.Configuration);
+
+ builder.Services.AddTvdbMetadataProvider(builder.Configuration);
+ builder.Services.AddTmdbMetadataProvider(builder.Configuration);
+ builder.Services.AddMalMetadataProvider(builder.Configuration);
+
+ builder.Services.AddAtProtoCore();
+
+ builder.Services.AddIdentityCore(
+ builder.Configuration.GetConnectionString("Identity")
+ ?? throw new InvalidOperationException("Missing ConnectionStrings:Identity configuration.")
+ );
+
+ builder.Services.Configure(builder.Configuration.GetSection("AtProtoOAuth"));
+ builder.Services.Configure(builder.Configuration.GetSection("AtProtoResolution"));
+ builder.Services.Configure(builder.Configuration.GetSection("AtProtoOAuth"));
+
+ builder.Services.AddSingleton();
+ builder.Services.AddHttpContextAccessor();
+ builder.Services.AddMemoryCache();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+
+ builder.Services.AddScoped();
+
+ builder.Services.AddAuthorizationBuilder()
+ .AddPolicy(AdminPolicy.Name, policy => policy.AddRequirements(new AdminRequirement()));
+
+ builder.Services.AddAuthentication(SessionAuthenticationHandler.SchemeName)
+ .AddScheme(
+ SessionAuthenticationHandler.SchemeName, null
+ );
+
+ customization?.BeforeTracking?.Invoke(builder.Services);
+
+ builder.Services.AddTrackingCore(
+ builder.Configuration.GetConnectionString("TrackingIndex")
+ ?? throw new InvalidOperationException("Missing ConnectionStrings:TrackingIndex configuration.")
+ );
+
+ // The one connection to Tap, which hands its events to the handlers registered by the services below.
+ builder.Services.AddTap();
+
+ // The Popfeed index lives in the database that was always called "Community" (schema popfeed).
+ builder.Services.AddPopfeedReviews(
+ builder.Configuration.GetConnectionString("Community")
+ ?? throw new InvalidOperationException("Missing ConnectionStrings:Community configuration.")
+ );
+
+ builder.Services.AddAniDbEnrichment(
+ builder.Configuration.GetConnectionString("MetadataCache")
+ ?? throw new InvalidOperationException("Missing ConnectionStrings:MetadataCache configuration.")
+ );
+
+ builder.Services.AddAnimeThemes();
+
+ builder.Services.AddCommunityCore();
+
+ builder.Services.AddStudioIntegration();
+
+ builder.Services.AddServiceStatus();
+
+ customization?.AfterRegistration?.Invoke(builder);
+
+ var app = builder.Build();
+
+ var logSettings = app.Services.GetRequiredService();
+
+ logSettings.Changed += () => levelSwitch.MinimumLevel = LogLevelSwitching.LevelFor(
+ configuredLevel, logSettings.Current.MinimumLevel
+ );
+
+ using (var scope = app.Services.CreateScope())
+ {
+ await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync();
+
+ await scope.ServiceProvider.GetRequiredService().InitializeAsync();
+ await scope.ServiceProvider.GetRequiredService().InitializeAsync();
+ await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync();
+
+ await scope.ServiceProvider.GetRequiredService()
+ .SeedAsync(app.Configuration.GetSection("Admin:InitialDids").Get() ?? []);
+
+ await using var trackingIndexDbContext = await scope.ServiceProvider
+ .GetRequiredService>()
+ .CreateDbContextAsync();
+
+ await trackingIndexDbContext.Database.MigrateAsync();
+
+ await using var popfeedDbContext = await scope.ServiceProvider
+ .GetRequiredService>()
+ .CreateDbContextAsync();
+
+ await popfeedDbContext.Database.MigrateAsync();
+
+ await using var aniDbDbContext = await scope.ServiceProvider.GetRequiredService>()
+ .CreateDbContextAsync();
+
+ await aniDbDbContext.Database.MigrateAsync();
+ }
+
+ // First, so everything logged for a request for the log (also its own request line) carries the flag.
+ app.UseLogRequestFlag();
+ app.UseForwardedHeaders();
+ app.UseForwardedPrefix();
+ app.UseExceptionMiddleware();
+
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapOpenApi();
+ }
+
+ app.UseSerilogRequestLogging();
+
+ if (app.Environment.IsDevelopment())
+ {
+ app.UseHttpsRedirection();
+ }
+
+ app.UseCors();
+ app.UseAuthentication();
+ app.UseAuthorization();
+ app.MapControllers();
+
+ await app.RunAsync();
+ }
+}
diff --git a/dotnet/src/Mediatracker.Api/ApiHostCustomization.cs b/dotnet/src/Mediatracker.Api/ApiHostCustomization.cs
new file mode 100644
index 00000000..b6488af5
--- /dev/null
+++ b/dotnet/src/Mediatracker.Api/ApiHostCustomization.cs
@@ -0,0 +1,21 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Mediatracker.Api;
+
+///
+/// Where a host that runs the API (the mock backend) may change it.
+///
+public class ApiHostCustomization
+{
+ ///
+ /// Called after the atproto services are registered and before the tracking services decorate the repo client
+ /// factory, so a different IAtProtoRepoClientFactory registered here is the one that gets decorated.
+ ///
+ public Action? BeforeTracking { get; init; }
+
+ ///
+ /// Called when everything is registered, before the host is built.
+ ///
+ public Action? AfterRegistration { get; init; }
+}
diff --git a/dotnet/src/Mediatracker.Api/Program.cs b/dotnet/src/Mediatracker.Api/Program.cs
index edf81a99..7f968117 100644
--- a/dotnet/src/Mediatracker.Api/Program.cs
+++ b/dotnet/src/Mediatracker.Api/Program.cs
@@ -1,249 +1,3 @@
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using Mediatracker.Administration;
-using Mediatracker.Administration.AspNetCore.AppVersion;
-using Mediatracker.Administration.AspNetCore.Authentication;
-using Mediatracker.Administration.AspNetCore.Controllers;
-using Mediatracker.Administration.AspNetCore.Http;
-using Mediatracker.Administration.AspNetCore.Logging;
-using Mediatracker.Administration.Logs;
-using Mediatracker.Administration.Settings;
-using Mediatracker.Api.Configuration;
-using Mediatracker.Api.Controllers.Auth;
-using Mediatracker.Api.Controllers.Sources;
-using Mediatracker.Api.Localization;
-using Mediatracker.Api.Logging;
-using Mediatracker.Api.Middleware;
-using Mediatracker.Api.Status;
-using Mediatracker.Api.StudioIntegration;
-using Mediatracker.AtProto;
-using Mediatracker.AtProto.OAuth;
-using Mediatracker.AtProto.Resolution;
-using Mediatracker.AtProto.ServiceAuth;
-using Mediatracker.Community;
-using Mediatracker.Identity;
-using Mediatracker.Identity.Admins;
-using Mediatracker.Identity.Sessions;
-using Mediatracker.Metadata;
-using Mediatracker.Metadata.AniDb;
-using Mediatracker.Metadata.AniDb.Storage;
-using Mediatracker.Metadata.AnimeThemes;
-using Mediatracker.Metadata.Caching;
-using Mediatracker.Metadata.Mal;
-using Mediatracker.Metadata.Popfeed;
-using Mediatracker.Metadata.Tmdb;
-using Mediatracker.Metadata.Tvdb;
-using Mediatracker.Tap;
-using Mediatracker.Tracking;
-using Mediatracker.Tracking.RecordIndex;
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.HttpOverrides;
-using Microsoft.EntityFrameworkCore;
-using Serilog;
-using Serilog.Core;
-using Serilog.Events;
+using Mediatracker.Api;
-var builder = WebApplication.CreateBuilder(args);
-
-// The console's log is configured in appsettings; the sink also keeps it in the database for the administration. The
-// level the logger lets through is a switch: the configuration's default, or lower if the log's own setting asks for
-// more (debug entries in the log), which the settings service moves at once (below).
-var configuredLevel = Enum.TryParse(
- builder.Configuration["Serilog:MinimumLevel:Default"], true, out var parsedLevel
-)
- ? parsedLevel
- : LogEventLevel.Information;
-
-var levelSwitch = new LoggingLevelSwitch(configuredLevel);
-
-builder.Host.UseSerilog((context, services, configuration) => configuration.ReadFrom
- .Configuration(context.Configuration)
- .MinimumLevel.ControlledBy(levelSwitch)
- .WriteTo.Sink(services.GetRequiredService())
-);
-
-builder.Services.AddSensitiveDataLoggingSetting();
-
-builder.Configuration.AddTvdbClient();
-builder.Configuration.AddMediatrackerEnvironment();
-
-// A login can make tokens for the Feed Studio only if it asked for the permission, and it asks for nothing the app
-// doesn't use: the scope is added only where a Studio is set up (read at the start: a change of the DID in the
-// administration applies to the scope after a restart).
-if (!string.IsNullOrWhiteSpace(builder.Configuration["StudioIntegration:ServiceDid"]))
-{
- builder.Configuration.AddInMemoryCollection(
- new Dictionary
- {
- ["AtProtoOAuth:AdditionalScope"]
- = $"{builder.Configuration["AtProtoOAuth:AdditionalScope"]} {ServiceAuthMethods.StudioLoginScope}"
- .Trim(),
- }
- );
-}
-
-builder.Services.AddScoped();
-
-builder.Services.AddControllers(options => options.Filters.Add())
- .AddJsonOptions(options
- => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase))
- );
-
-builder.Services.AddOpenApi();
-
-builder.Services.AddProblemDetails();
-builder.Services.AddTransient();
-builder.Services.AddForwardedPrefix();
-
-// Behind a TLS-terminating reverse proxy the request arrives as plain http; trust its X-Forwarded-* headers so the
-// scheme and host are the ones the client used. The proxy's address isn't known up front (a container network).
-builder.Services.Configure(options =>
- {
- options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
- options.KnownIPNetworks.Clear();
- options.KnownProxies.Clear();
- }
-);
-
-builder.Services.AddCors(options
- => options.AddDefaultPolicy(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())
-);
-
-builder.Services.AddMetadataCore(
- builder.Configuration.GetConnectionString("MetadataCache")
- ?? throw new InvalidOperationException("Missing ConnectionStrings:MetadataCache configuration.")
-);
-
-builder.Services.AddExternalLinks();
-
-// The last source of the configuration: what an admin sets for the sources wins over files and environment.
-builder.Services.AddSourceSettings(builder.Configuration);
-
-builder.Services.AddLogStore();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-
-builder.Services.AddHostedService();
-
-builder.Services.AddImageProxy(builder.Configuration);
-
-builder.Services.AddTvdbMetadataProvider(builder.Configuration);
-builder.Services.AddTmdbMetadataProvider(builder.Configuration);
-builder.Services.AddMalMetadataProvider(builder.Configuration);
-
-builder.Services.AddAtProtoCore();
-
-builder.Services.AddIdentityCore(
- builder.Configuration.GetConnectionString("Identity")
- ?? throw new InvalidOperationException("Missing ConnectionStrings:Identity configuration.")
-);
-
-builder.Services.Configure(builder.Configuration.GetSection("AtProtoOAuth"));
-builder.Services.Configure(builder.Configuration.GetSection("AtProtoResolution"));
-builder.Services.Configure(builder.Configuration.GetSection("AtProtoOAuth"));
-
-builder.Services.AddSingleton();
-builder.Services.AddHttpContextAccessor();
-builder.Services.AddMemoryCache();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-
-builder.Services.AddScoped();
-
-builder.Services.AddAuthorizationBuilder()
- .AddPolicy(AdminPolicy.Name, policy => policy.AddRequirements(new AdminRequirement()));
-
-builder.Services.AddAuthentication(SessionAuthenticationHandler.SchemeName)
- .AddScheme(
- SessionAuthenticationHandler.SchemeName, null
- );
-
-builder.Services.AddTrackingCore(
- builder.Configuration.GetConnectionString("TrackingIndex")
- ?? throw new InvalidOperationException("Missing ConnectionStrings:TrackingIndex configuration.")
-);
-
-// The one connection to Tap, which hands its events to the handlers registered by the services below.
-builder.Services.AddTap();
-
-// The Popfeed index lives in the database that was always called "Community" (schema popfeed).
-builder.Services.AddPopfeedReviews(
- builder.Configuration.GetConnectionString("Community")
- ?? throw new InvalidOperationException("Missing ConnectionStrings:Community configuration.")
-);
-
-builder.Services.AddAniDbEnrichment(
- builder.Configuration.GetConnectionString("MetadataCache")
- ?? throw new InvalidOperationException("Missing ConnectionStrings:MetadataCache configuration.")
-);
-
-builder.Services.AddAnimeThemes();
-
-builder.Services.AddCommunityCore();
-
-builder.Services.AddStudioIntegration();
-
-builder.Services.AddServiceStatus();
-
-var app = builder.Build();
-
-var logSettings = app.Services.GetRequiredService();
-
-logSettings.Changed += () => levelSwitch.MinimumLevel = LogLevelSwitching.LevelFor(
- configuredLevel, logSettings.Current.MinimumLevel
-);
-
-using (var scope = app.Services.CreateScope())
-{
- await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync();
-
- await scope.ServiceProvider.GetRequiredService().InitializeAsync();
- await scope.ServiceProvider.GetRequiredService().InitializeAsync();
- await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync();
-
- await scope.ServiceProvider.GetRequiredService()
- .SeedAsync(app.Configuration.GetSection("Admin:InitialDids").Get() ?? []);
-
- await using var trackingIndexDbContext = await scope.ServiceProvider
- .GetRequiredService>()
- .CreateDbContextAsync();
-
- await trackingIndexDbContext.Database.MigrateAsync();
-
- await using var popfeedDbContext = await scope.ServiceProvider
- .GetRequiredService>()
- .CreateDbContextAsync();
-
- await popfeedDbContext.Database.MigrateAsync();
-
- await using var aniDbDbContext = await scope.ServiceProvider.GetRequiredService>()
- .CreateDbContextAsync();
-
- await aniDbDbContext.Database.MigrateAsync();
-}
-
-// First, so everything logged for a request for the log (also its own request line) carries the flag.
-app.UseLogRequestFlag();
-app.UseForwardedHeaders();
-app.UseForwardedPrefix();
-app.UseExceptionMiddleware();
-
-if (app.Environment.IsDevelopment())
-{
- app.MapOpenApi();
-}
-
-app.UseSerilogRequestLogging();
-
-if (app.Environment.IsDevelopment())
-{
- app.UseHttpsRedirection();
-}
-
-app.UseCors();
-app.UseAuthentication();
-app.UseAuthorization();
-app.MapControllers();
-
-app.Run();
+await ApiHost.RunAsync(new WebApplicationOptions { Args = args, });
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakeDidHandleResolver.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakeDidHandleResolver.cs
index 200d1623..bf1e7ce1 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakeDidHandleResolver.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakeDidHandleResolver.cs
@@ -1,6 +1,6 @@
using Mediatracker.AtProto.Resolution;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// Finds the handle of a DID after the time a DID document takes to arrive.
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakeFeedGeneratorResolver.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakeFeedGeneratorResolver.cs
index cc9d75af..34479df2 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakeFeedGeneratorResolver.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakeFeedGeneratorResolver.cs
@@ -1,6 +1,6 @@
using Mediatracker.Tracking.Feeds;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// Finds every feed at once, as a service of a publisher with the latency of a request to a PDS and a DID
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakeFeedServiceClient.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakeFeedServiceClient.cs
index e5f839e6..d069b5e8 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakeFeedServiceClient.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakeFeedServiceClient.cs
@@ -2,7 +2,7 @@ using Mediatracker.Metadata;
using Mediatracker.Tracking.Feeds;
using Mediatracker.Tracking.Library;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// A feed service with lists of titles each, taken from the
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakeHostApplicationLifetime.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakeHostApplicationLifetime.cs
index bff30e97..9e2f969e 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakeHostApplicationLifetime.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakeHostApplicationLifetime.cs
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Hosting;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// The application lifetime of a process that is never stopped from inside, for services that ask for it.
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakeMovieMetadataProvider.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakeMovieMetadataProvider.cs
index 8a2a155a..a8275e03 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakeMovieMetadataProvider.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakeMovieMetadataProvider.cs
@@ -6,13 +6,14 @@ using Mediatracker.Metadata.Media;
using Mediatracker.Metadata.Movies;
using Mediatracker.Metadata.Reviews;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// A movie metadata provider that answers from the after the latency of a
/// real source.
///
public class FakeMovieMetadataProvider : IMovieMetadataProvider,
+ IMovieMetadataPagesProvider,
IMovieRecommendationsProvider,
IMovieCollectionProvider,
IMovieMediaProvider,
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakePds.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakePds.cs
index 30d0bf41..4913e2f5 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakePds.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakePds.cs
@@ -2,7 +2,7 @@ using System.Text.Json;
using System.Text.Json.Nodes;
using Mediatracker.AtProto.Repo;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// An in-memory PDS repo of one account that answers like the real one, slowly: a page of 100 records costs
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakePdsRegistry.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakePdsRegistry.cs
index e84dcedb..aa4aea55 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakePdsRegistry.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakePdsRegistry.cs
@@ -1,6 +1,6 @@
using System.Collections.Concurrent;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// The repos of all simulated accounts, by DID: each user of a load test has a PDS of their own.
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakeRepoClient.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakeRepoClient.cs
index 6a3ecd7b..83c14f74 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakeRepoClient.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakeRepoClient.cs
@@ -1,6 +1,6 @@
using Mediatracker.AtProto.Repo;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// An on a .
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakeRepoClientFactory.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakeRepoClientFactory.cs
index a82a43a3..e51e8009 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakeRepoClientFactory.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakeRepoClientFactory.cs
@@ -1,7 +1,7 @@
using Mediatracker.AtProto;
using Mediatracker.AtProto.Repo;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// An whose clients talk to the of the
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakeSourceLatency.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakeSourceLatency.cs
index 507d579f..a28ae286 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakeSourceLatency.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakeSourceLatency.cs
@@ -3,7 +3,7 @@ using Mediatracker.Metadata.RateLimiting;
using Mediatracker.Metadata.Tmdb.Client;
using TMDbLib.Objects.Exceptions;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// How a (fake) metadata source answers: it takes per call (a round trip to TMDB or
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakeTapBackfill.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakeTapBackfill.cs
index 80483121..c3e9b8c5 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakeTapBackfill.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakeTapBackfill.cs
@@ -3,7 +3,7 @@ using Mediatracker.AtProto.Tap;
using Mediatracker.Tracking.RecordIndex;
using Microsoft.Extensions.DependencyInjection;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// A Tap that reads a simulated account the way the real one does: the whole repo in one go (not page after page)
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakeTitleResolver.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakeTitleResolver.cs
index 47b0da3c..44ebf195 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakeTitleResolver.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakeTitleResolver.cs
@@ -3,7 +3,7 @@ using Mediatracker.Metadata.IdMappings;
using Mediatracker.Metadata.Media;
using Mediatracker.Metadata.TitleResolution;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// A title resolver that sends every MyAnimeList title of the feeds to the catalogue show of the same number,
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/FakeTvShowMetadataProvider.cs b/dotnet/src/Mediatracker.Testing/Fakes/FakeTvShowMetadataProvider.cs
index 2d10a452..ad3021b6 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/FakeTvShowMetadataProvider.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/FakeTvShowMetadataProvider.cs
@@ -6,13 +6,14 @@ using Mediatracker.Metadata.Media;
using Mediatracker.Metadata.Reviews;
using Mediatracker.Metadata.TvShows;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// A TV show metadata provider that answers from the after the latency of a
/// real source.
///
public class FakeTvShowMetadataProvider : ITvShowMetadataProvider,
+ ITvShowMetadataPagesProvider,
ITvShowRecommendationsProvider,
ITvShowMediaProvider,
ITvShowAlternativeTitlesProvider,
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/PassThroughImageDeduplicator.cs b/dotnet/src/Mediatracker.Testing/Fakes/PassThroughImageDeduplicator.cs
index 0a3ab57a..956dee8c 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/PassThroughImageDeduplicator.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/PassThroughImageDeduplicator.cs
@@ -1,6 +1,6 @@
using Mediatracker.Metadata.Images;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// An image deduplicator that finds nothing to remove: the simulated sources deliver no real pictures to compare.
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/ProviderCallLog.cs b/dotnet/src/Mediatracker.Testing/Fakes/ProviderCallLog.cs
index 323c88fa..be0de7db 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/ProviderCallLog.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/ProviderCallLog.cs
@@ -1,7 +1,7 @@
using System.Collections.Concurrent;
using System.Diagnostics;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// Counts the calls the fake metadata providers get, which are the calls a real deployment makes to TMDB or
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/StoredRecord.cs b/dotnet/src/Mediatracker.Testing/Fakes/StoredRecord.cs
index 423122a2..e7f8a1f2 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/StoredRecord.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/StoredRecord.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Nodes;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// A record in the .
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/SyntheticAccount.cs b/dotnet/src/Mediatracker.Testing/Fakes/SyntheticAccount.cs
index 14b28b82..6f38b5e5 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/SyntheticAccount.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/SyntheticAccount.cs
@@ -1,9 +1,9 @@
using System.Text.Json.Nodes;
using Mediatracker.AtProto.Repo;
using Mediatracker.Metadata.TvShows;
-using Mediatracker.Performance.Tests.Shape;
+using Mediatracker.Testing.Shape;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// Fills a and a with an account of the size and
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/SyntheticCatalogue.cs b/dotnet/src/Mediatracker.Testing/Fakes/SyntheticCatalogue.cs
index fa22d4f0..eb3fd11b 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/SyntheticCatalogue.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/SyntheticCatalogue.cs
@@ -2,7 +2,7 @@ using Mediatracker.Metadata;
using Mediatracker.Metadata.Movies;
using Mediatracker.Metadata.TvShows;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// What the (fake) metadata source knows: the movies, TV shows, seasons and episodes of the synthetic account.
diff --git a/dotnet/src/Mediatracker.Testing/Fakes/SyntheticWorld.cs b/dotnet/src/Mediatracker.Testing/Fakes/SyntheticWorld.cs
index 70f8abd4..80008df2 100644
--- a/dotnet/src/Mediatracker.Testing/Fakes/SyntheticWorld.cs
+++ b/dotnet/src/Mediatracker.Testing/Fakes/SyntheticWorld.cs
@@ -1,7 +1,7 @@
using Mediatracker.Metadata.TvShows;
-using Mediatracker.Performance.Tests.Shape;
+using Mediatracker.Testing.Shape;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Testing.Fakes;
///
/// Grows the from the titles of the reference account to the titles of a whole
diff --git a/dotnet/src/Mediatracker.Testing/Mediatracker.Testing.csproj b/dotnet/src/Mediatracker.Testing/Mediatracker.Testing.csproj
new file mode 100644
index 00000000..b7d2d7ff
--- /dev/null
+++ b/dotnet/src/Mediatracker.Testing/Mediatracker.Testing.csproj
@@ -0,0 +1,20 @@
+
+
+
+
+ false
+ $(NoWarn);CS1591
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Mediatracker.Testing/Shape/AccountShape.cs b/dotnet/src/Mediatracker.Testing/Shape/AccountShape.cs
index 12a2167a..45dc692d 100644
--- a/dotnet/src/Mediatracker.Testing/Shape/AccountShape.cs
+++ b/dotnet/src/Mediatracker.Testing/Shape/AccountShape.cs
@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
-namespace Mediatracker.Performance.Tests.Shape;
+namespace Mediatracker.Testing.Shape;
///
/// The size and structure of a real account (numbers only, no titles), which the synthetic account of the
diff --git a/dotnet/src/Mediatracker.Testing/Shape/MovieShape.cs b/dotnet/src/Mediatracker.Testing/Shape/MovieShape.cs
index 1a340068..30b5fc43 100644
--- a/dotnet/src/Mediatracker.Testing/Shape/MovieShape.cs
+++ b/dotnet/src/Mediatracker.Testing/Shape/MovieShape.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace Mediatracker.Performance.Tests.Shape;
+namespace Mediatracker.Testing.Shape;
///
/// What the real account has of one movie: whether it is tracked, how often it was watched, whether it is rated.
diff --git a/dotnet/src/Mediatracker.Testing/Shape/ShowShape.cs b/dotnet/src/Mediatracker.Testing/Shape/ShowShape.cs
index 5de044ca..bd6c9640 100644
--- a/dotnet/src/Mediatracker.Testing/Shape/ShowShape.cs
+++ b/dotnet/src/Mediatracker.Testing/Shape/ShowShape.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace Mediatracker.Performance.Tests.Shape;
+namespace Mediatracker.Testing.Shape;
///
/// What the real account has of one TV show: the episode count of each season, how many episodes were
diff --git a/dotnet/tests/Mediatracker.Performance.Tests/Load/LoadPopulation.cs b/dotnet/tests/Mediatracker.Performance.Tests/Load/LoadPopulation.cs
index a172f12b..eaf7d9fb 100644
--- a/dotnet/tests/Mediatracker.Performance.Tests/Load/LoadPopulation.cs
+++ b/dotnet/tests/Mediatracker.Performance.Tests/Load/LoadPopulation.cs
@@ -1,5 +1,5 @@
-using Mediatracker.Performance.Tests.Fakes;
-using Mediatracker.Performance.Tests.Shape;
+using Mediatracker.Testing.Fakes;
+using Mediatracker.Testing.Shape;
using Mediatracker.Tracking.RecordIndex;
using Microsoft.Extensions.DependencyInjection;
diff --git a/dotnet/tests/Mediatracker.Performance.Tests/Load/SyntheticUser.cs b/dotnet/tests/Mediatracker.Performance.Tests/Load/SyntheticUser.cs
index 7882d6a8..4e677c9b 100644
--- a/dotnet/tests/Mediatracker.Performance.Tests/Load/SyntheticUser.cs
+++ b/dotnet/tests/Mediatracker.Performance.Tests/Load/SyntheticUser.cs
@@ -1,8 +1,8 @@
using System.Text.Json.Nodes;
-using Mediatracker.Performance.Tests.Load;
-using Mediatracker.Performance.Tests.Shape;
+using Mediatracker.Testing.Fakes;
+using Mediatracker.Testing.Shape;
-namespace Mediatracker.Performance.Tests.Fakes;
+namespace Mediatracker.Performance.Tests.Load;
///
/// Fills a with the library of one user: a random share of the titles of the shared
diff --git a/dotnet/tests/Mediatracker.Performance.Tests/Load/UserActions.Feeds.cs b/dotnet/tests/Mediatracker.Performance.Tests/Load/UserActions.Feeds.cs
index 88b672af..78fe0bb1 100644
--- a/dotnet/tests/Mediatracker.Performance.Tests/Load/UserActions.Feeds.cs
+++ b/dotnet/tests/Mediatracker.Performance.Tests/Load/UserActions.Feeds.cs
@@ -1,4 +1,4 @@
-using Mediatracker.Performance.Tests.Fakes;
+using Mediatracker.Testing.Fakes;
using Mediatracker.Tracking.FeedDirectory;
using Mediatracker.Tracking.Feeds;
using Mediatracker.Tracking.Library;
diff --git a/dotnet/tests/Mediatracker.Performance.Tests/Load/VirtualUser.cs b/dotnet/tests/Mediatracker.Performance.Tests/Load/VirtualUser.cs
index 3de2e159..b5dd3bd3 100644
--- a/dotnet/tests/Mediatracker.Performance.Tests/Load/VirtualUser.cs
+++ b/dotnet/tests/Mediatracker.Performance.Tests/Load/VirtualUser.cs
@@ -1,5 +1,5 @@
using Mediatracker.Identity.Sessions;
-using Mediatracker.Performance.Tests.Fakes;
+using Mediatracker.Testing.Fakes;
namespace Mediatracker.Performance.Tests.Load;
diff --git a/dotnet/tests/Mediatracker.Performance.Tests/Mediatracker.Performance.Tests.csproj b/dotnet/tests/Mediatracker.Performance.Tests/Mediatracker.Performance.Tests.csproj
index a536890b..b4d00d49 100644
--- a/dotnet/tests/Mediatracker.Performance.Tests/Mediatracker.Performance.Tests.csproj
+++ b/dotnet/tests/Mediatracker.Performance.Tests/Mediatracker.Performance.Tests.csproj
@@ -18,12 +18,9 @@
-
-
-
-
+
diff --git a/dotnet/tests/Mediatracker.Performance.Tests/PerfEnvironment.cs b/dotnet/tests/Mediatracker.Performance.Tests/PerfEnvironment.cs
index bf29cd79..ee363025 100644
--- a/dotnet/tests/Mediatracker.Performance.Tests/PerfEnvironment.cs
+++ b/dotnet/tests/Mediatracker.Performance.Tests/PerfEnvironment.cs
@@ -10,8 +10,8 @@ using Mediatracker.Metadata.Images;
using Mediatracker.Metadata.Movies;
using Mediatracker.Metadata.TitleResolution;
using Mediatracker.Metadata.TvShows;
-using Mediatracker.Performance.Tests.Fakes;
-using Mediatracker.Performance.Tests.Shape;
+using Mediatracker.Testing.Fakes;
+using Mediatracker.Testing.Shape;
using Mediatracker.Tracking;
using Mediatracker.Tracking.FeedDirectory;
using Mediatracker.Tracking.Feeds;
diff --git a/frontend/.run/Start Mediatracker (Mock).run.xml b/frontend/.run/Start Mediatracker (Mock).run.xml
new file mode 100644
index 00000000..694cb714
--- /dev/null
+++ b/frontend/.run/Start Mediatracker (Mock).run.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/apps/mediatracker/vite.config.ts b/frontend/apps/mediatracker/vite.config.ts
index 43170e96..0c86ff56 100644
--- a/frontend/apps/mediatracker/vite.config.ts
+++ b/frontend/apps/mediatracker/vite.config.ts
@@ -36,11 +36,12 @@ export default defineConfig({
// The API's loopback OAuth login sends the browser back to http://127.0.0.1:/api/auth/callback (atproto
// allows no "localhost" there), so the dev server has to listen on that address, not only on ::1.
host: '127.0.0.1',
- // Like the web container in a deployment: /api is the backend (native dev run or the dev compose stack on :13000).
+ // Like the web container in a deployment: /api is the backend (native dev run or the dev compose stack on :13000;
+ // MEDIATRACKER_API_PROXY points it elsewhere, e.g. at the mock backend on :13001).
// The prefix is cut and handed on as X-Forwarded-Prefix, so the API builds its OAuth redirect address with it.
proxy: {
'/api': {
- target: 'http://localhost:13000',
+ target: process.env.MEDIATRACKER_API_PROXY ?? 'http://localhost:13000',
rewrite: (path) => path.replace(/^\/api/, ''),
headers: { 'X-Forwarded-Prefix': '/api' },
},