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 @@ + + + + + +