diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 41a33aea..c7c22b74 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,10 +49,51 @@ jobs: - name: Format check run: dotnet format Sources/ProjectV.sln --severity warn --verify-no-changes - - name: Test (C# projects) + # Linux: four sequential named test stages with Coverlet + # collection on the C# stages (via coverlet.runsettings). + - name: Test (Unit) if: matrix.os == 'ubuntu-latest' - run: dotnet test Sources/ProjectV.sln --configuration Release --no-build + run: dotnet test Sources/ProjectV.sln --configuration Release --no-build --filter "Category=Unit" --collect:"XPlat Code Coverage" --settings Sources/Tests/coverlet.runsettings - - name: Test (F# ContentDirectories) + - name: Test (Integration) + if: matrix.os == 'ubuntu-latest' + run: dotnet test Sources/ProjectV.sln --configuration Release --no-build --filter "Category=Integration" --collect:"XPlat Code Coverage" --settings Sources/Tests/coverlet.runsettings + + - name: Test (Contract) + if: matrix.os == 'ubuntu-latest' + run: dotnet test Sources/ProjectV.sln --configuration Release --no-build --filter "Category=Contract" --collect:"XPlat Code Coverage" --settings Sources/Tests/coverlet.runsettings + + # F# stage: explicit fsproj invocation, no Category filter, no coverage collection + # (F# coverage non-essential and the project is tiny). + - name: Test (F#) if: matrix.os == 'ubuntu-latest' run: dotnet test Sources/Tests/ProjectV.ContentDirectories.Tests/ProjectV.ContentDirectories.Tests.fsproj --configuration Release --no-build -p:Platform=x64 + + # Coverage publication: merge per-stage Cobertura outputs into + # one HTML artifact + a Markdown step-summary panel. + - name: Merge coverage reports + if: matrix.os == 'ubuntu-latest' + uses: danielpalme/ReportGenerator-GitHub-Action@5 + with: + reports: '**/TestResults/**/coverage.cobertura.xml' + targetdir: 'coverage-report' + reporttypes: 'HtmlInline;MarkdownSummaryGithub' + verbosity: 'Warning' + + - name: Upload coverage report artifact + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage-report/ + + - name: Write coverage summary to Step Summary + if: matrix.os == 'ubuntu-latest' + run: cat coverage-report/SummaryGithub.md >> $GITHUB_STEP_SUMMARY + + # Windows: non-Docker tests. Single-quoted filter so PowerShell does not + # interpret `!=` and YAML keeps the string verbatim. Docker-dependent + # Testcontainers tests stay Linux-only via the [Trait("RequiresDocker","true")] tag. + - name: Test (Non-Docker) + if: matrix.os == 'windows-latest' + run: dotnet test Sources/ProjectV.sln --configuration Release --no-build --filter 'RequiresDocker!=true' diff --git a/Docs/Testing/Coverage/test-coverage.md b/Docs/Testing/Coverage/test-coverage.md new file mode 100644 index 00000000..c46f14a1 --- /dev/null +++ b/Docs/Testing/Coverage/test-coverage.md @@ -0,0 +1,126 @@ +# ProjectV Test Coverage Inventory + +**Last updated:** 2026-05-18 +**Milestone:** v0.9.8 — Test Coverage +**Document role:** Critical-path coverage inventory; design contract for the test suite added in PR #342. + +## Purpose + +This document enumerates the critical paths across Domain (appraisal logic, model invariants, +F# policy / activities), Application (`Shell`, `ShellBuilder`, `DataflowPipeline`, `Executors`), +and Infrastructure (DB + TMDb / OMDb / Steam adapters) layers and maps each path to the tests +that cover it. + +## How downstream work updates this file + +When a row's covering test file lands, update it by: + +1. Flipping the row `Status` column from `planned` (or `partially covered`) to + `covered` once the row's planned test project actually exercises the path. +2. Adding a new `Test Files` column on the right with the path(s) of the + committed test file(s) that cover the row (e.g. + `Sources/Tests/ProjectV.Appraisers.Tests/AppraiserTests.cs`). +3. Never deleting rows. Paths can be promoted to `tested around` if an + architectural anti-pattern decision pushes them out of direct test scope; + the row stays as the audit trail. + +Cross-references: this document is the source of truth that +[`projectv-scenario-tests-overview.md`](../Scenarios/projectv-scenario-tests-overview.md) +points back to. + +## Legend + +| Column | Meaning | +|----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Path` | The critical-path entry point — class/method/scenario being verified. | +| `Component` | The production library or web service that owns the path. | +| `Planned Test Project` | The canonical `ProjectV..Tests` project that will hold the test(s). Convention: one test project per production library; `ProjectV.Tests.Shared` holds shared test infrastructure. | +| `Test Type` | `Unit` (NSubstitute-mocked collaborators, AwesomeAssertions on return) / `Integration` (real composition, real Testcontainers Postgres, real EF Core) / `Contract` (WireMock.Net HTTP stubs fed from recorded JSON fixtures) / `Unit (F#)` (Unquote quoted-expression assertions, F# stack stays as-is). | +| `Status` | `planned` (no covering test yet) / `partially covered` (some coverage exists, more needed) / `covered` (verified by a committed test, see Test Files) / `tested around` (path is verified through a higher-level path; an architectural anti-pattern means we test what's there, not what we wish were there). | + +### Status vocabulary + +- `planned` — no covering test in the repo today. +- `partially covered` — at least one test exists; the remaining shape is named in the row notes. +- `partially covered (skip resolved)` — the historical `[Fact(Skip = "…")]` blocker on the `BasicInfo` JSON round-trip in `ProjectV.Common.Tests` was removed as part of the test-bootstrap retrofit (PR #342). Row stays `partially covered` because the broader model-invariants surface ports to `ProjectV.Models.Tests` per row. +- `covered` — a committed test file under `Sources/Tests/ProjectV..Tests/` exercises the path; the test-file path is listed in the `Test Files` column. +- `tested around` — the path is exercised indirectly through a higher-level integration test because an architectural anti-pattern blocks direct unit testing (`Shell` references concrete plugin assemblies; `SimpleExecutor.ExecuteAsync()` is a `NotImplementedException` stub; `ServiceRequestProcessor.CreateExecutorAsync` rebuilds the pipeline per request). + +## Trait conventions + +Every C# test class declares one `Category` trait. Integration tests that +depend on a Docker daemon (Testcontainers) add the `RequiresDocker` trait too. +F# tests skip the `Category` trait — they run as their own named CI stage via +the explicit `fsproj` invocation (`Test (F#)` stage in CI). + +- Unit tests: `[Trait("Category","Unit")]` +- Integration tests: `[Trait("Category","Integration")]` and (when Testcontainers is involved) `[Trait("RequiresDocker","true")]` +- Contract tests: `[Trait("Category","Contract")]` +- F# tests: no `Category` trait — run separately in CI (`Test (F#)` stage). + +## Domain Layer + +| Path | Component | Planned Test Project | Test Type | Status | Test Files | +|------|-----------|----------------------|-----------|--------|------------| +| `Appraiser.GetRatings` — property defaults, null-arg, 1/3/N items | `ProjectV.Appraisers` | `ProjectV.Appraisers.Tests` | Unit | covered | `Sources/Tests/ProjectV.Appraisers.Tests/AppraiserTests.cs` | +| `Appraiser` + `TmdbCommonAppraisal` — movie-common rating computation accuracy | `ProjectV.Appraisers` | `ProjectV.Appraisers.Tests` | Unit | covered | `Sources/Tests/ProjectV.Appraisers.Tests/AppraisersExtensions/MovieCommonAppraiserTests.cs` | +| `Appraiser` + `BasicAppraisalNormalized` — movie-normalized rating computation accuracy | `ProjectV.Appraisers` | `ProjectV.Appraisers.Tests` | Unit | covered | `Sources/Tests/ProjectV.Appraisers.Tests/AppraisersExtensions/MovieNormalizedAppraiserTests.cs` | +| `Appraiser` + Steam/`Omdb` appraisals — game-common / game-normalized rating computation accuracy | `ProjectV.Appraisers` | `ProjectV.Appraisers.Tests` | Unit | planned | — | +| `AppraisersManager` — add/remove appraisers, `CreateFlow()` shape | `ProjectV.Appraisers` | `ProjectV.Appraisers.Tests` | Unit | covered | `Sources/Tests/ProjectV.Appraisers.Tests/AppraisersExtensions/AppraisersManagerTests.cs` | +| `BasicInfo` model invariants + Newtonsoft.Json round-trip | `ProjectV.Models` | `ProjectV.Models.Tests` | Unit | covered | `Sources/Tests/ProjectV.Models.Tests/Data/BasicInfoInvariantsTests.cs`, `Sources/Tests/ProjectV.Common.Tests/ModelSerializationTests.cs` | +| `MovieInfo`, `GameInfo` model invariants + JSON round-trip | `ProjectV.Models` | `ProjectV.Models.Tests` | Unit | planned | — | +| Custom exception types (`CannotGetTmdbConfigException`, etc.) — 3-ctor convention | `ProjectV.Models` | `ProjectV.Models.Tests` | Unit | covered | `Sources/Tests/ProjectV.Models.Tests/Exceptions/CannotGetTmdbConfigExceptionTests.cs`, `Sources/Tests/ProjectV.Models.Tests/Exceptions/CommonExceptionsTestSuite.cs` | +| `UserId`, `JobId` value-object behavior — `Create`, `Parse`, `None`, `Wrap`, `TryParse`, `IsSpecified` | `ProjectV.Models` | `ProjectV.Models.Tests` | Unit | covered | `Sources/Tests/ProjectV.Models.Tests/ValueObjects/UserIdTests.cs`, `Sources/Tests/ProjectV.Models.Tests/ValueObjects/JobIdTests.cs` | +| `ProjectV.Activities.PolicyModels` — retry policy construction | `ProjectV.Activities` | `ProjectV.Activities.Tests` (F# or C# wrapper) | Unit | planned | — | +| `ProjectV.ContentDirectories.ContentFinder` — guard clauses on bad paths | `ProjectV.ContentDirectories` | `ProjectV.ContentDirectories.Tests` | Unit (F#) | covered | `Sources/Tests/ProjectV.ContentDirectories.Tests/ContentFinderTests.fs` | + +## Application Layer + +| Path | Component | Planned Test Project | Test Type | Status | Test Files | +|------|-----------|----------------------|-----------|--------|------------| +| `Shell.Run` — success path, error path (`ServiceStatus.Error`), output-error path | `ProjectV.Core` | `ProjectV.Core.Tests` | Unit (mocked managers) | tested around — the Gridsum.DataflowEx empty-pipeline deadlock prevents full `Run` branch coverage; Shell's constructor null-guards (5 args), property surface, `Dispose` idempotency, and the `CreateBuilderDirector` static factory ARE covered at Unit; full `Run` branch coverage is deferred to a future E2E or JWT integration plan. | `Sources/Tests/ProjectV.Core.Tests/ShellTests.cs` | +| `ShellBuilderFromXDocument` — builds Shell from minimal valid XDocument | `ProjectV.Core` | `ProjectV.Core.Tests` | Unit | covered (ctor null-guard, missing-root guard, minimal-config happy path, GetResult-before-build guard, Reset, BuildMessageHandler missing-element error path) | `Sources/Tests/ProjectV.Core.Tests/ShellBuilders/ShellBuilderFromXDocumentTests.cs` | +| `ShellBuilderDirector` — director invokes all 4 builder steps in order | `ProjectV.Core` | `ProjectV.Core.Tests` | Unit | covered (ctor null-guard, ctor happy path, ChangeShellBuilder null-guard, MakeShell invokes all 7 steps, MakeShell invokes them in declared order, MakeShell dispatches to replaced builder) | `Sources/Tests/ProjectV.Core.Tests/ShellBuilders/ShellBuilderDirectorTests.cs` | +| `DataflowPipeline.Execute` — stages connected, data flows end-to-end | `ProjectV.DataPipeline` | `ProjectV.DataPipeline.Tests` | Integration (real dataflow, mocked `ICrawler`/`IAppraiser`) | covered (single-item happy path through the four real Gridsum.DataflowEx stages with substitute `ICrawler` + `IAppraiser` leaves; the production `DataflowPipeline.Execute(string)` uses the single-arg `InputtersFlow.ProcessAsync(...)` overload that deadlocks on terminal pipelines, so the integration test drives the same wiring via the two-arg `ProcessAsync(..., completeFlowOnFinish: true)`) | `Sources/Tests/ProjectV.DataPipeline.Tests/DataflowPipelineTests.cs` | +| `InputtersFlow` — deduplication of repeated input items + `MinWordLength > 2` length filter | `ProjectV.DataPipeline` | `ProjectV.DataPipeline.Tests` | Integration (real Gridsum.DataflowEx block; reflection probe on the private `FilterInputData` predicate to avoid the predicated-link completion deadlock) | covered (dedup branch — unique items pass, duplicates filtered; length branch — `Length > MinWordLength (2)` survivors only; happy-path end-to-end smoke with no filtering) | `Sources/Tests/ProjectV.DataPipeline.Tests/InputtersFlowTests.cs` | +| `CrawlersManager.TryGetResponse` — rethrows original exception on child-crawler failure | `ProjectV.Crawlers` | `ProjectV.Crawlers.Tests` | Unit | covered (rethrow assertion via reflection on the private method with a throwing `ICrawler` substitute). The `_logger.Error(...)` side-effect is NOT directly substituted in this Unit suite because the logger is a `private static readonly` field initialised via `LoggerFactory.CreateLoggerFor()`. Also covers constructor + `Add` null-guard + `Remove` happy path. | `Sources/Tests/ProjectV.Crawlers.Tests/CrawlersManagerTests.cs` | +| `InputManager.CreateFlow` — returns non-null `InputtersFlow` for empty + populated registrations and empty storage-name fallback | `ProjectV.InputProcessing` | `ProjectV.InputProcessing.Tests` | Unit | covered (CreateFlow non-null with/without registered inputters; empty storage-name → default fallback; ctor null/whitespace guard; Add null-guard; Remove round-trip) | `Sources/Tests/ProjectV.InputProcessing.Tests/InputManagerTests.cs` | +| `OutputManager.CreateFlow` — returns non-null `OutputtersFlow` for empty + populated registrations and empty storage-name fallback | `ProjectV.OutputProcessing` | `ProjectV.OutputProcessing.Tests` | Unit | covered (CreateFlow non-null with/without registered outputters; empty storage-name → default fallback; ctor null/whitespace guard; Add null-guard; Remove round-trip) | `Sources/Tests/ProjectV.OutputProcessing.Tests/OutputManagerTests.cs` | +| `SimpleExecutor.ExecuteAsync()` — parameterless overload throws `NotImplementedException` | `ProjectV.Executors` | `ProjectV.Executors.Tests` | Unit | covered (tested around — the parameterless overload is an unfinished stub that throws rather than executing real job logic; the test asserts the current throw behaviour. Also covers ctor null-guard on `jobInfo`, `ArgumentOutOfRangeException` on non-positive `executionsNumber`, and happy-path property exposure.) | `Sources/Tests/ProjectV.Executors.Tests/SimpleExecutorTests.cs` | +| `CommunicationServiceClient.LoginAsync` — happy path + 401 auth failure | `ProjectV.Core` | `ProjectV.Core.Tests` | Unit (NSubstitute IHttpClientFactory + FakeHttpMessageHandler) | covered (200 → `Result.Ok`; 401 → `Result.Error`; null-arg guard). `StartJobAsync` happy path deferred to integration — the token-cache pre-flight + refresh-on-unauthorized policy chain requires real composition to exercise meaningfully. | `Sources/Tests/ProjectV.Core.Tests/Net/CommunicationServiceClientTests.cs` | +| `AddHttpClientWithOptions` + Polly retry policy wiring — retry fires on transient HTTP error | `ProjectV.Core` | `ProjectV.Core.Tests` | Unit (FakeHttpMessageHandler DelegatingHandler) | covered (503 → 503 → 503 → 200 with `RetryCountOnFailed=3` → 4 invocations; always-503 → 1 + N retries; first-call-200 → 1 invocation) | `Sources/Tests/ProjectV.Core.Tests/Net/HttpClientPollyPolicyTests.cs` | + +## Infrastructure Layer + +| Path | Component | Planned Test Project | Test Type | Status | Test Files | +|------|-----------|----------------------|-----------|--------|------------| +| `DatabaseJobInfoService.AddJobAsync` / `GetJobAsync` / `UpdateJobAsync` — round-trip | `ProjectV.DataAccessLayer` | `ProjectV.DataAccessLayer.Tests` | Integration (Testcontainers) | covered (3 tests: Add returns persisted row count, FindById round-trip, Update mutation persists. Schema applied via raw SQL `CREATE TABLE` in `DbCollectionFixture.ApplySchemaAsync` — EF Core migration generation was deferred because of a design-time model discovery failure.) | `Sources/Tests/ProjectV.DataAccessLayer.Tests/Services/Jobs/DatabaseJobInfoServiceTests.cs` | +| `DatabaseUserInfoService.AddUserAsync` / `GetUserAsync` | `ProjectV.DataAccessLayer` | `ProjectV.DataAccessLayer.Tests` | Integration (Testcontainers) | covered (3 tests: Add returns persisted row count, FindById round-trip, FindByUserName lookup. Required a production fix to the `WrappedUserName` LINQ expression so EF Core can translate it to SQL.) | `Sources/Tests/ProjectV.DataAccessLayer.Tests/Services/Users/DatabaseUserInfoServiceTests.cs` | +| `DatabaseRefreshTokenInfoService.AddTokenAsync` / expiry behavior | `ProjectV.DataAccessLayer` | `ProjectV.DataAccessLayer.Tests` | Integration (Testcontainers) | covered (2 tests: Add returns persisted row count, FindById round-trip preserves the seven-day UTC expiry timestamp. Required a production fix to the `WrappedUserId` LINQ expression in `FindByUserIdAsync` so EF Core can translate it to SQL.) | `Sources/Tests/ProjectV.DataAccessLayer.Tests/Services/Tokens/DatabaseRefreshTokenInfoServiceTests.cs` | +| `ProjectVDbContext` schema — tables exist, constraints enforced | `ProjectV.DataAccessLayer` | `ProjectV.DataAccessLayer.Tests` | Integration (Testcontainers) | covered (2 tests: `information_schema` query asserts `public.{jobs,users,tokens}` exist; `CanUseDb()` returns true on every fixture-built context and the Npgsql connection opens. The schema bootstrap path is raw SQL — `MigrateAsync`/`EnsureCreatedAsync` both fail because of the production model bug fixed in PR #342.) | `Sources/Tests/ProjectV.DataAccessLayer.Tests/ProjectVDbContextSchemaTests.cs` | +| `TmdbClient.TrySearchMovieAsync` / `GetConfigAsync` — search success, empty-result envelope, configuration fetch (`GetMovieAsync` does NOT exist in the production wrapper) | `ProjectV.TmdbService` | `ProjectV.TmdbService.Tests` | Contract (WireMock) | covered (3 tests exercise the real `TMDbLib` HTTP pipeline against WireMock-served recorded JSON; redirection seam: `new TmdbClient(apiKey, useSsl: false, baseUrl: WireMockHostPort)` via `InternalsVisibleTo`) | `Sources/Tests/ProjectV.TmdbService.Tests/TmdbContractTests.cs` | +| `OmdbClient.TryGetItemByTitleAsync` — success response, false-response swallowed | `ProjectV.OmdbService` | `ProjectV.OmdbService.Tests` | Contract (WireMock) | covered (2 tests exercise the real `OMDbApiNet` HTTP pipeline against WireMock-served recorded JSON; redirection seam: `HttpClient.DefaultProxy = new WebProxy(WireMock.Url)` because OMDbApiNet 1.3.0 hardcodes `BaseUrl` as a `const` field) | `Sources/Tests/ProjectV.OmdbService.Tests/OmdbContractTests.cs` | +| `SteamApiClient.GetAppListAsync` / `TryGetSteamAppAsync` | `ProjectV.SteamService` | `ProjectV.SteamService.Tests` | Contract (WireMock) | covered (2 tests exercise the real `SteamWebApiLib` HTTP pipeline against WireMock-served recorded JSON; redirection seam: reflection-replace the wrapper's `_steamApiClient` with an SDK instance built from a `SteamApiConfig` whose `SteamPoweredBaseUrl` + `SteamStoreBaseUrl` point at WireMock) | `Sources/Tests/ProjectV.SteamService.Tests/SteamContractTests.cs` | +| CommunicationWebService — `POST /api/v1/Requests` with valid JWT → 200 | `ProjectV.CommunicationWebService` | `ProjectV.CommunicationWebService.Tests` | Integration (WebApplicationFactory) | covered (Scenario JWT-2: bearer token signed via the production HS256 key + same issuer/audience the host was configured with; assertion asserts the response is NOT 401 / 403 — auth pipeline accepts the token. The empty body may surface a 400 from the configuration receiver — what matters is the JWT middleware did not short-circuit.) | `Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtAuthenticatedRequestTests.cs` | +| CommunicationWebService — `POST /api/v1/Requests` without JWT → 401 | `ProjectV.CommunicationWebService` | `ProjectV.CommunicationWebService.Tests` | Integration (WebApplicationFactory) | covered (Scenario JWT-1: anonymous POST returns 401 Unauthorized through the production `AddJtwAuthentication` middleware. No `[Trait("RequiresDocker", "true")]` — JWT path uses `InMemoryUserInfoService`, no DB required.) | `Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtAnonymousRequestTests.cs` | +| CommunicationWebService — `POST /api/v1/Users/Login` — valid credentials → JWT issued | `ProjectV.CommunicationWebService` | `ProjectV.CommunicationWebService.Tests` | Integration (WebApplicationFactory) | covered (Scenario JWT-3: seeds a single user in `InMemoryUserInfoService` via the production `IPasswordManager` salt + PBKDF2 hash, POSTs to `/api/v1/Users/login`, asserts 200 + a non-empty `AccessToken.Token` field with case-insensitive property lookup — `AddNewtonsoftJson` defaults to camelCase.) | `Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtLoginIssuesTokenTests.cs` | +| TelegramBotWebService webhook — `POST /api/v1/Update` with valid Update payload → 200 | `ProjectV.TelegramBotWebService` | `ProjectV.TelegramBotWebService.Tests` | Integration (WebApplicationFactory) | covered (Scenario TG-WEB-1: synthetic Update with `/start` text message POSTed at `/api/v1/Update`; `IBotService` is replaced by an NSubstitute substitute whose `BotClient` returns a `TestTelegramBotClientBuilder` stub; `ICommunicationServiceClient` is also stubbed so handler resolution does not blow up on production options validation. Scenario TG-WEB-2: malformed JSON returns 4xx via the production `AddNewtonsoftJson` model binder.) | `Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/TelegramWebhookTextMessageTests.cs`, `Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/TelegramWebhookInvalidPayloadTests.cs` | +| TelegramBotWebService polling — `PoolingProcessor` processes a fixed Update sequence | `ProjectV.TelegramBotWebService` | `ProjectV.TelegramBotWebService.Tests` | Integration (WebApplicationFactory) | covered (Scenario TG-POLL-1: `WorkingMode=PollingViaHostedService` so `IHost.StartAsync()` runs the production `PoolingProcessor` `BackgroundService`; `IBotService` is substituted (with `DeleteWebhookAsync` + `SendMessageAsync` stubbed deterministically), `IBotService.BotClient` returns a `TestTelegramBotClientBuilder.WithUpdateSequence(...)`-built stub primed with three text-message updates; assertion waits with a bounded 15-second `CancellationTokenSource` and verifies the production handler chain called `IBotService.SendMessageAsync` at least once per update.) | `Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Polling/TelegramPollingProcessesUpdateSequenceTests.cs` | +| ProcessingWebService — `POST /api/v1/Processing` smoke test (config + pipeline construction) | `ProjectV.ProcessingWebService` | `ProjectV.ProcessingWebService.Tests` | Integration (WebApplicationFactory, WireMock) | planned | — | + +## Maintenance + +When a row's covering test file lands, update this document: + +- Flip the `Status` from `planned` (or `partially covered`) to `covered` and + append a `Test Files` column on the right of that table containing the + repo-relative path(s) to the test file(s) that exercise the row. +- Never delete rows. If an architectural change pushes a path out of direct + test scope, promote it to `tested around` and add a one-sentence note + pointing at the higher-level test that now exercises it (or explaining the + anti-pattern that drove the indirection). +- New critical paths discovered later are added as new rows under the + matching layer section — keep the table header stable so the diff stays + reviewable. + +See also [`Docs/Testing/Scenarios/projectv-scenario-tests-overview.md`](../Scenarios/projectv-scenario-tests-overview.md) for scenario-test architecture, mermaid diagram, and conventions for the `Scenarios/` subdirectory rows above. diff --git a/Docs/Testing/Scenarios/projectv-jwt-scenarios.md b/Docs/Testing/Scenarios/projectv-jwt-scenarios.md new file mode 100644 index 00000000..dfba81ca --- /dev/null +++ b/Docs/Testing/Scenarios/projectv-jwt-scenarios.md @@ -0,0 +1,132 @@ +# ProjectV JWT Scenario Tests + +Companion to +[`projectv-scenario-tests-overview.md`](./projectv-scenario-tests-overview.md) +and [`../Coverage/test-coverage.md`](../Coverage/test-coverage.md). +This document is the per-family scenario doc for the JWT-authentication +slice of `ProjectV.CommunicationWebService`. Scenarios live under +`Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/` and +inherit the conventions described in the overview doc. + +## Purpose + +Cover the JWT runtime path of `ProjectV.CommunicationWebService` end-to-end +through `WebApplicationFactory` — without mocking the authentication +pipeline. The scenarios exercise: + +- The bearer-token validation path wired by + `AddJtwAuthentication(jwtConfig)` in + `Sources/WebServices/ProjectV.CommonWebApi/Service/Extensions/ServiceCollectionExtensions.cs`. +- The login round-trip exposed by + `Sources/WebServices/ProjectV.CommunicationWebService/v1/Controllers/UsersController.cs` + (`POST /api/v1/Users/login`). +- The protected entry point in + `Sources/WebServices/ProjectV.CommunicationWebService/v1/Controllers/RequestsController.cs` + (`POST /api/v1/Requests`). + +The JWT path uses the in-memory user store +(`InMemoryUserInfoService`) — these tests do NOT require Testcontainers, +so they carry only `[Trait("Category", "Integration")]` (no +`[Trait("RequiresDocker", "true")]`) and run on both the Linux Integration +stage and the Windows Non-Docker stage of CI. + +## Audience + +- **Test authors** adding new JWT scenarios — for example expired-token, + malformed-Authorization-header, or refresh-token-flow tests. They inherit + from `JwtAuthScenarioBaseTest` and follow the conventions below. +- **Reviewers** scanning the family folder — the class XML doc on each + test file reads like a business-language sentence so a reviewer can scan + the directory and immediately see what behaviour is covered. + +## Architecture + +Each test class inherits the family base +[`JwtAuthScenarioBaseTest`](../../../Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtAuthScenarioBaseTest.cs), +which extends `ProjectV.Tests.Shared.ForTests.WebApiBaseTest`. The +base test wires up an in-process +`TestWebApplicationFactory` that +injects a deterministic JWT signing key +(`TestJwtConfig.DefaultSecretKeyBase64`) into the host's +`IConfiguration` BEFORE `Startup.ConfigureServices` runs — that timing is +the only seam that lets the test side mint tokens with the same HMAC +SHA-256 secret the production `AddJwtBearer` registration validates against. + +```mermaid +flowchart LR + TF[Test Fixture
JwtAuthScenarioBaseTest] + TF --> WAF[TestWebApplicationFactory<Startup>] + WAF --> CAC[ConfigureAppConfiguration
inject JwtOptions:SecretKey,
Issuer, Audience] + WAF --> CTS[ConfigureTestServices
per-scenario DI overrides] + CAC --> HOST[(Hosted CommunicationWebService
real Startup + middleware)] + CTS --> HOST + HOST -->|HTTP loopback| HC[HttpClient
anonymous OR Bearer-decorated] + TF -->|CreateAuthenticatedClient| HC + TF -->|TestJwtHelper| TOK[Signed test JWT
HMAC SHA-256] + TOK --> HC + HC --> CTRL[RequestsController / UsersController] +``` + +## Scenario Catalog + +| Scenario | Test File | Endpoint | Expected Outcome | +|----------|-----------|----------|------------------| +| **JWT-1** — Anonymous request rejected | `JwtAnonymousRequestTests.cs` | `POST /api/v1/Requests` (no `Authorization`) | `401 Unauthorized` | +| **JWT-2** — Authenticated request passes auth | `JwtAuthenticatedRequestTests.cs` | `POST /api/v1/Requests` (valid bearer token) | Status code is NOT 401 / 403 (auth pipeline accepts the token) | +| **JWT-3** — Login issues token | `JwtLoginIssuesTokenTests.cs` | `POST /api/v1/Users/login` (valid in-memory creds) | `200 OK` + `TokenResponse` with non-empty `AccessToken.Token` | + +### Scenario JWT-1: Anonymous request rejected + +When no `Authorization` header is present on a request to a +`[Authorize]`-decorated controller action, the production JWT bearer +middleware must short-circuit the pipeline with HTTP `401 Unauthorized`. +Verifies that the auth wiring is present at all — a regression that +silently disabled authentication (e.g. removing `app.UseAuthentication()` +or `[Authorize]`) would let the request through with a 400 instead. + +### Scenario JWT-2: Authenticated request passes the auth pipeline + +A token signed with the same secret / issuer / audience the host was +configured with must pass `TokenValidationParameters` so the request +reaches the controller action. The scenario asserts that the response is +neither 401 nor 403; it does NOT assert on the response body shape +because the request body is intentionally empty (the controller may +short-circuit with 400 for that reason — what matters is that the auth +middleware did NOT short-circuit). + +### Scenario JWT-3: Login round-trip issues a token pair + +The scenario seeds a single user into the in-memory user store via the +production `IPasswordManager` so the stored salt + hash format match +exactly what `UserService.LoginAsync` expects. It then POSTs credentials +at `/api/v1/Users/login` and asserts the response is `200 OK` with a +non-empty `AccessToken.Token` field. The `ShouldCreateSystemUser` flag is +held OFF to avoid the fire-and-forget seed race in +`UserService`'s constructor — the test owns the entire in-memory store. + +## Conventions + +JWT scenario tests follow the conventions described in +[`projectv-scenario-tests-overview.md`](./projectv-scenario-tests-overview.md#conventions) +without exception. Two family-specific points: + +- **No `[Trait("RequiresDocker", "true")]`** — JWT scenarios use only the + in-memory user store. They run on the Windows Non-Docker stage of CI in + addition to the Linux Integration stage. +- **No `[Collection]` attribute** — JWT scenarios do NOT share a fixture + with the Testcontainers Postgres path used by the DAL integration suite. + Each scenario class spins up its own in-process host via the factory in + `InitializeAsync` and tears it down in `DisposeAsync`. + +## Cross-references + +- [`Docs/Testing/Coverage/test-coverage.md`](../Coverage/test-coverage.md) — + Infrastructure-Layer rows for the three JWT scenarios. +- [`Docs/Testing/Scenarios/projectv-scenario-tests-overview.md`](./projectv-scenario-tests-overview.md) — + cross-family conventions, architecture diagram, scenario-test pattern. +- [`Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestWebApplicationFactory.cs`](../../../Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestWebApplicationFactory.cs) — + generic test host wrapper. +- [`Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestJwtHelper.cs`](../../../Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestJwtHelper.cs) — + bearer-token issuance helper. +- [`Sources/Tests/ProjectV.Tests.Shared/ForTests/WebApiBaseTest.cs`](../../../Sources/Tests/ProjectV.Tests.Shared/ForTests/WebApiBaseTest.cs) — + `IAsyncLifetime` base + `CreateAuthenticatedClient`. diff --git a/Docs/Testing/Scenarios/projectv-scenario-tests-overview.md b/Docs/Testing/Scenarios/projectv-scenario-tests-overview.md new file mode 100644 index 00000000..7ae78764 --- /dev/null +++ b/Docs/Testing/Scenarios/projectv-scenario-tests-overview.md @@ -0,0 +1,181 @@ +# ProjectV Scenario Tests — Overview + +Companion to +[`Docs/Testing/Coverage/test-coverage.md`](../Coverage/test-coverage.md). +This document is the architecture-diagram baseline for the +`WebApplicationFactory`-based scenario suites covering JWT authentication, +Telegram webhook, and Telegram polling. Per-family scenario docs +(e.g. `projectv-jwt-scenarios.md`, `projectv-telegram-scenarios.md`, +`projectv-tmdb-pipeline-scenarios.md`, …) are added alongside their +respective scenario suites as they land. + +## Purpose + +Scenario tests in ProjectV are integration tests written one test file per +business scenario: + +- One sealed test class per scenario; file name is `Tests.cs`. +- Class XML doc summarises the scenario in **business** terms (e.g. + "Scenario JWT-1: Anonymous request to `/api/v1/Requests` returns 401"), + not in test-framework terms. +- Class inherits from a per-family base class — e.g. `JwtAuthScenarioBaseTest`, + `TelegramWebhookScenarioBaseTest`, `TmdbPipelineScenarioBaseTest` — which + bundles the `WebApplicationFactory` wiring + scenario-family-wide config knobs. +- Test method bodies use **explicit `// Arrange.` / `// Act.` / `// Assert.` + comment markers**. This convention was introduced during the test-coverage + work tracked in PR #342; new scenario tests follow it without exception. +- Assertions cover production behavior AND stub-side call counts where + relevant — for example, `wireMock.LogEntries.Should().HaveCount(1)` to + verify that the SDK called the external API exactly once after a Polly + retry policy completed. + +The point is that the file name, class name, and XML doc together read like +a checklist of business behaviour, so a reviewer can scan the +`Scenarios//` directory and see exactly what is being verified +without opening any test method. + +## Audience + +- **Scenario test authors** — engineers implementing `WebApplicationFactory`-based + integration tests for the JWT authentication + (`Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/`), + Telegram webhook + (`Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/`), + or Telegram polling + (`Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Polling/`) + suites. They use this overview + to know which base class to inherit from, which Helpers wires up which + external surface, and what shape an Arrange / Act / Assert block should + take inside the scenario file. +- **Future contributors** adding new scenario families — they create a new + per-family base class under + `Sources/Tests/ProjectV..Tests/Scenarios//`, + add a per-family doc next to this overview, and follow the conventions + named here. +- **Reviewers** of phase-end PRs — they use the diagram below to confirm + that a new scenario test wires up the real production DI graph (no mocks + on the request path) and that any external dependency lives behind a + WireMock.Net stub. + +Scenario tests live under +`Sources/Tests/ProjectV..Tests/Scenarios//` — +one directory per scenario family, one file per scenario inside it. + +## Architecture + +The diagram below shows how a scenario test process drives the system under +test, including the `WebApplicationFactory` integration branch used by the +JWT and Telegram scenario suites. + +Key invariants in the diagram: + +- The **Real Application** node represents the production DI graph — the + same `Startup` class production runs, the same `ICrawler` / `IAppraiser` + / `IJobInfoService` registrations, the same EF Core `ProjectVDbContext`. +- The **only test doubles on the request path** are `WireMockServer` + instances for external HTTP APIs (TMDb / OMDb / Steam) and a substituted + `ITelegramBotClient` for the Telegram polling branch. There are no + in-process mocks for the Application or Domain layers in scenario tests + (that is the Unit-test layer's job). +- The **Testcontainers Postgres** node is the single per-test-run + container started by `ICollectionFixture`; the same + container is reused across scenario test classes that share the + `DbCollection` `CollectionDefinition`. + +```mermaid +flowchart TD + TP[Test Process
xUnit + AwesomeAssertions + NSubstitute] + + TP --> UT[Unit Tests
Category=Unit] + UT --> NS[NSubstitute substitutes] + NS --> SUT[Single SUT class] + SUT --> AA1[AwesomeAssertions on return value] + + TP --> IT[Integration Tests
Category=Integration] + IT --> WAF[WebApplicationFactory<TStartup>] + WAF --> CTS[ConfigureTestServices
swap connection string &
stub HTTP clients] + CTS --> RA[Real Application DI graph
Startup + EF Core + Polly] + RA --> TC[(Testcontainers
PostgreSqlContainer)] + RA -.->|external HTTP| WMI[WireMockServer
recorded JSON fixtures] + + TP --> CT[Contract Tests
Category=Contract] + CT --> WMS[WireMockServer in-process] + WMS -.->|HTTP loopback| HCF[IHttpClientFactory] + HCF --> SDK[Real SDK
TMDbLib / OmdbApiNet / SteamWebApiLib] + SDK --> ADP[Adapter mapper] + ADP --> AA2[AwesomeAssertions on
BasicInfo / MovieInfo / GameInfo] + + TP --> FT[F# Tests
separate fsproj invocation,
no Category trait] + FT --> UQ[Unquote quoted-expression
assertions] + UQ --> CF[ContentFinder / PolicyModels] + + classDef testDouble stroke-dasharray: 5 5; + class WMI,WMS testDouble; +``` + +The dashed edges (`-.->`) and dashed-border nodes mark the only places where +a scenario test substitutes a real dependency: HTTP traffic to TMDb / OMDb / +Steam is routed through a local `WireMockServer` instance that serves recorded +JSON fixtures from `Sources/Tests/Fixtures/{Tmdb,Omdb,Steam}/`. Everything +else on the request path is production code running against a real +Testcontainers Postgres. + +## Scenario Family Documents + +Per-family docs are added by the plan that lands the family's scenario suite, +not up-front: + +> Only scenario-family docs that correspond to scenario suites actually +> committed to the repository are created — the overview is mandatory, +> family docs are added as their scenario suites land. + +Expected per-family doc filenames: + +- `projectv-jwt-scenarios.md` — added alongside the JWT scenario suite + (`Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/`). +- `projectv-telegram-scenarios.md` — added alongside the Telegram webhook + + polling scenario suites + (`Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/` and `/Polling/`). +- `projectv-tmdb-pipeline-scenarios.md` — added if/when a TMDb-end-to-end + scenario suite lands; current TMDb coverage is at the contract-test + layer (`Sources/Tests/ProjectV.TmdbService.Tests/TmdbContractTests.cs`). + +Family docs follow the same shape as this overview — Purpose, Audience, +Architecture (with a scenario-family-specific mermaid view), Conventions, +and a table that enumerates each scenario file with a one-line description. + +## Conventions + +- **Class XML doc** summarises the scenario in business terms. Bad: + `"Tests that the controller returns 401 when no Authorization header is + present."` Good: `"Scenario JWT-1: Anonymous request to /api/v1/Requests + returns 401."` +- **Class shape** — `public sealed class Tests` with an + explicit empty constructor (matches the rest of the ProjectV test stack). +- **Base class** — inherits from a per-family base class (e.g. + `JwtAuthScenarioBaseTest`) that holds the `WebApplicationFactory` + instance + scenario-family-wide config knobs. The base class is what + swaps test-side HttpClients onto WireMock and tells the + `ProjectVDbContext` to point at the Testcontainers Postgres. +- **AAA markers** — every test method body has explicit `// Arrange.`, + `// Act.`, and `// Assert.` comment lines. No exceptions; even one-line + acts include the marker. +- **Assertions** — assert on production behavior AND on stub-side call + counts where the stub-side counts are part of the scenario semantics. + Example: a "Polly retries transient 502 exactly once" scenario asserts + on the final 200 response AND on `wireMockServer.LogEntries.Should() + .HaveCount(2, "Polly should have retried once after the transient + failure")`. +- **Category trait** — every scenario test class is + `[Trait("Category","Integration")]`. Scenarios that hit Testcontainers + Postgres also add `[Trait("RequiresDocker","true")]`. CI filters on these + traits to separate Docker-dependent tests from non-Docker integration tests. +- **xUnit collection** — scenario tests that share the Testcontainers + Postgres declare `[Collection(DbCollection.Name)]` so they run serially + inside the single container session. + +## Cross-references + +- [`Docs/Testing/Coverage/test-coverage.md`](../Coverage/test-coverage.md) — + critical-path coverage inventory; the scenarios documented here cover the + `WebApplicationFactory` rows in the Infrastructure Layer table. diff --git a/Docs/Testing/Scenarios/projectv-telegram-scenarios.md b/Docs/Testing/Scenarios/projectv-telegram-scenarios.md new file mode 100644 index 00000000..cc9470ee --- /dev/null +++ b/Docs/Testing/Scenarios/projectv-telegram-scenarios.md @@ -0,0 +1,238 @@ +# ProjectV Telegram Scenario Tests + +Companion to +[`projectv-scenario-tests-overview.md`](./projectv-scenario-tests-overview.md) +and [`../Coverage/test-coverage.md`](../Coverage/test-coverage.md). +This document is the per-family scenario doc for the Telegram-bot slice of +`ProjectV.TelegramBotWebService`. It covers both halves of the scenario suite: + +- **Webhook scenarios** (Telegram webhook scenarios — `Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/`) — synthetic Telegram + `Update` JSON payloads POSTed at the production webhook endpoint via + `WebApplicationFactory`. Live in + `Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/`. +- **Polling scenarios** (Telegram polling scenarios — `Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Polling/`) — the production + `PoolingProcessor` hosted service exercised end-to-end with a substituted + `ITelegramBotClient` that yields a fixed sequence of `Update`s. Live in + `Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Polling/`. + +Both halves share the conventions described in the overview doc. + +## Purpose + +Cover the full Telegram-bot path of `ProjectV.TelegramBotWebService` +end-to-end without contacting the live Telegram API. The scenarios exercise: + +- The production webhook controller + `Sources/WebServices/ProjectV.TelegramBotWebService/v1/Controllers/UpdateController.cs` + (`POST /api/v1/Update`). +- The handler chain `IUpdateService` → `IBotHandler` + (`BotMessageHandler`) → `IBotService.SendMessageAsync`. +- The full ASP.NET Core middleware stack including the custom + `ExceptionMiddleware`, JWT bearer authentication (anonymous on this + endpoint), and the API-versioning by-namespace convention that maps the + controller to `/api/v1/Update`. +- For polling scenarios the `PoolingProcessor` hosted service plus the + `BotPolling` → `ITelegramBotClient.ReceiveAsync` → `BotPollingUpdateHandler` + → `UpdateService` → `BotMessageHandler` chain, exercised with a scripted + update sequence supplied through + `TestTelegramBotClientBuilder.WithUpdateSequence(...)`. + +The Telegram bot path uses `IBotService` as the natural test seam: the +production `BotService` ctor instantiates a real `TelegramBotClient(BotToken, +HttpClient)` and throws on an empty `BotToken` — so every scenario test +replaces `IBotService` with an NSubstitute substitute whose `BotClient` +property returns a `TestTelegramBotClientBuilder`-produced +`ITelegramBotClient` stub. The webhook scenarios carry only +`[Trait("Category", "Integration")]` (no `[Trait("RequiresDocker", "true")]`) +because the webhook path does not touch the database; they run on both the +Linux Integration stage and the Windows Non-Docker stage of CI. + +## Audience + +- **Test authors** adding new Telegram-bot scenarios — for example expired- + authentication, command-with-bad-arguments, or specific `Update` types + beyond `Message` (callback queries, edited messages, etc.). They inherit + from `TelegramWebhookScenarioBaseTest` (for webhook scenarios) or + `TelegramPollingScenarioBaseTest` (for polling scenarios) and follow the + conventions below. +- **Reviewers** scanning the family folder — the class XML doc on each test + file reads like a business-language sentence so a reviewer can scan the + directory and immediately see what behaviour is covered. + +## Architecture + +Each webhook test class inherits the family base +[`TelegramWebhookScenarioBaseTest`](../../../Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/TelegramWebhookScenarioBaseTest.cs), +which extends `ProjectV.Tests.Shared.ForTests.WebApiBaseTest`. The +base wires up an in-process +`TestWebApplicationFactory` with: + +- **In-memory configuration overrides** — sets + `TelegramBotWebServiceOptions:WorkingMode` to `WebhookViaServiceSetup` so + the production polling / webhook hosted services are NOT registered (their + ctors would resolve `IBotService` before the test-side swap), and supplies + a dummy non-empty `Bot:Token` so `BotOptions.Validate()` does not throw. +- **`IBotService` swap** — removes the production singleton and re-registers + an NSubstitute substitute whose `BotClient` property returns the supplied + `ITelegramBotClient` stub from + [`TestTelegramBotClientBuilder`](../../../Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Telegram/TestTelegramBotClientBuilder.cs). +- **`ICommunicationServiceClient` swap** — removes the production transient + and re-registers a no-setup + [`TestCommunicationServiceClientBuilder.CreateWithoutSetup()`](../../../Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Core/TestCommunicationServiceClientBuilder.cs) + stub so handler resolution does not try to construct the production + `CommunicationServiceClient` (which has a strict options-validation + chain that fails in tests). + +```mermaid +flowchart LR + TF[Test Fixture
TelegramWebhookScenarioBaseTest] + TF --> WAF[TestWebApplicationFactory<Startup>] + WAF --> CFG[ConfigureAppConfiguration
WorkingMode=WebhookViaServiceSetup,
BotToken=dummy] + WAF --> CTS[ConfigureTestServices
Swap IBotService + ICommunicationServiceClient] + CFG --> HOST[(Hosted TelegramBotWebService
real Startup + middleware)] + CTS --> HOST + HOST -->|HTTP loopback| HC[HttpClient] + HC --> CTRL[UpdateController.Post / GetInfo] + CTRL --> US[IUpdateService] + US --> BMH[BotMessageHandler] + BMH -->|SendMessage swallowed| BS[IBotService stub] + BS --> TBC[ITelegramBotClient stub] +``` + +## Scenario Catalog + +| Scenario | Test File | Driver | Expected Outcome | +|----------|-----------|--------|------------------| +| **TG-WEB-1** — Valid text-message Update | `TelegramWebhookTextMessageTests.cs` | `POST /api/v1/Update` with a `Telegram.Bot.Types.Update` containing a `/start` `Message` | `200 OK` (handler chain runs end-to-end) | +| **TG-WEB-2** — Malformed JSON rejected | `TelegramWebhookInvalidPayloadTests.cs` | `POST /api/v1/Update` with `{ not valid json` body | `4xx` client error from the `AddNewtonsoftJson` model binder | +| **TG-POLL-1** — Polling drains a scripted Update sequence | `TelegramPollingProcessesUpdateSequenceTests.cs` | `PoolingProcessor` `BackgroundService` started by the host with `WorkingMode=PollingViaHostedService`; substitute `ITelegramBotClient` pre-loaded via `TestTelegramBotClientBuilder.WithUpdateSequence` yields three text-message updates on the first poll | `IBotService.SendMessageAsync` is called at least once per update (handler chain runs end-to-end through the polling path) | + +### Scenario TG-WEB-1: Valid text-message Update + +A synthetic `Update` with a `Message` carrying the `/start` text reaches the +webhook controller, deserialises through `AddNewtonsoftJson`, flows into +`IUpdateService.HandleUpdateAsync`, dispatches to `BotMessageHandler`, and +the bot handler's `SendMessageAsync` call hits the substituted `IBotService` +(no-op). The scenario asserts the controller returns `200 OK` — that single +status proves the entire model-binding + auth + middleware + handler chain +is healthy on the webhook path. The scenario does NOT assert on outgoing +bot calls; that level of verification belongs to the bot-message-handler +unit-test layer covering `BotMessageHandler` and its collaborators. + +### Scenario TG-WEB-2: Malformed JSON rejected + +A request body that is not valid JSON is rejected by the production +model-binder pipeline before the action runs. With `[ApiController]` on the +controller, ASP.NET Core auto-rejects an unbound model state with HTTP 400. +The scenario asserts the status code is in the 4xx range — the exact value +comes from the production +`AddNewtonsoftJson` configuration, not from any code in this plan, so the +test asserts the production behavior as-is rather than dictating a specific +400 versus 415 outcome (the scenario tests existing semantics, does not +change them). + +## Polling Scenarios + +Polling tests inherit the family base +[`TelegramPollingScenarioBaseTest`](../../../Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Polling/TelegramPollingScenarioBaseTest.cs), +which (like the webhook base) extends +`ProjectV.Tests.Shared.ForTests.WebApiBaseTest`. Two things differ +from the webhook base: + +- **`WorkingMode=PollingViaHostedService`** — the production `Startup` + registers `PoolingProcessor` as a `BackgroundService` only under this + working mode. The host starts the background polling loop when + `TestWebApplicationFactory.CreateClient()` triggers `IHost.StartAsync()`, + AFTER `ConfigureTestServices` has swapped `IBotService`. The polling + processor resolves `IBotPolling` → which depends on `IBotService` → which + is the test-side substitute by the time the resolution happens. +- **`IBotService.DeleteWebhookAsync` + `IBotService.SendMessageAsync` + stubbed explicitly** — `BotPolling.StartReceivingUpdatesAsync` calls + `DeleteWebhookAsync` before entering the receive loop, and the + `BotMessageHandler` chain calls `SendMessageAsync` for every update it + drains. Stubbing both deterministically lets the polling loop run + uninterrupted and lets the test assert on the substituted + `SendMessageAsync` call-count to verify the handler chain ran end-to-end. + +```mermaid +flowchart LR + TF[Test Fixture
TelegramPollingScenarioBaseTest] + TF --> WAF[TestWebApplicationFactory<Startup>] + WAF --> CFG[ConfigureAppConfiguration
WorkingMode=PollingViaHostedService,
BotToken=dummy] + WAF --> CTS[ConfigureTestServices
Swap IBotService + ICommunicationServiceClient] + CFG --> HOST[(Hosted TelegramBotWebService
IHost.StartAsync runs PoolingProcessor)] + CTS --> HOST + HOST --> PP[PoolingProcessor.ExecuteAsync] + PP --> BP[BotPolling.StartReceivingUpdatesAsync] + BP --> BS[IBotService stub
DeleteWebhookAsync stubbed] + BP --> RA[BotClient.ReceiveAsync extension] + RA --> SR[ITelegramBotClient.SendRequest<Update[]>
yields scripted batch then empty] + SR --> UH[BotPollingUpdateHandler.HandleUpdateAsync] + UH --> US[UpdateService.HandleUpdateAsync] + US --> BMH[BotMessageHandler.ProcessAsync] + BMH --> SM[IBotService.SendMessageAsync
Received(N) → assertion] +``` + +### Scenario TG-POLL-1: Polling drains a scripted Update sequence + +A `TestTelegramBotClientBuilder.WithUpdateSequence(...)` substitute is built +with three text-message updates (`/start`, `/help`, and a freeform "Hello +there"). The substitute primes the bot client's `SendRequest` so +the first poll yields the scripted batch and every subsequent poll yields +an empty array. The host's `PoolingProcessor` starts the receive loop on +`IHost.StartAsync()`; the loop forwards each update through +`BotPollingUpdateHandler` → `UpdateService.HandleUpdateAsync` → +`BotMessageHandler.ProcessAsync` → `IBotService.SendMessageAsync`. The +scenario waits on the substituted `IBotService` with a bounded 15-second +timeout (a polling loop must never hang the suite) and asserts the `SendMessageAsync` call-count is at +least 3. The single-method assertion proves the entire polling chain is +healthy end-to-end without relying on internal details of the Telegram +receiver implementation. + +## Conventions + +Telegram scenario tests — both webhook and polling — follow the conventions +described in +[`projectv-scenario-tests-overview.md`](./projectv-scenario-tests-overview.md#conventions) +without exception. Three family-specific points: + +- **No `[Trait("RequiresDocker", "true")]`** — webhook AND polling + scenarios run entirely in-process; no Testcontainers Postgres is + started. They run on the Windows Non-Docker stage of CI in addition to + the Linux Integration stage. Polling does not need the DB any + more than webhook does — the production polling loop runs against the + substituted bot client, which never reaches the real + `CommunicationServiceClient`'s downstream-job persistence path on + the commands these scenarios exercise. +- **`IBotService` is the natural seam** — not `ITelegramBotClient`. The + production `BotService.BotClient` getter returns the live bot client; + substituting `IBotService` directly (with `BotClient` returning the + `ITelegramBotClient` stub) keeps the production ctor's bot-token check + out of the test path. `TestTelegramBotClientBuilder` builds the + `ITelegramBotClient` substitute; the per-family base class composes it + inside the `IBotService` substitute via NSubstitute's `Returns(...)`. + For polling, the same base also stubs + `IBotService.DeleteWebhookAsync` and `IBotService.SendMessageAsync` so + the receive loop runs uninterrupted and the test can read the + `SendMessageAsync` call-count back deterministically. +- **Bounded polling loop** — every polling scenario uses a + `CancellationTokenSource(TimeSpan.FromSeconds(15))` (or shorter) when + waiting on the substituted bot client. A hosted polling service must + never hang the suite, even when the substitute is misconfigured. + Disposing the + `TestWebApplicationFactory` (handled by `WebApiBaseTest.DisposeAsync`) + signals the host's stopping token and tears the loop down cleanly. + +## Cross-references + +- [`Docs/Testing/Coverage/test-coverage.md`](../Coverage/test-coverage.md) — + Infrastructure-Layer rows for the TelegramBotWebService webhook and + polling scenarios. +- [`Docs/Testing/Scenarios/projectv-scenario-tests-overview.md`](./projectv-scenario-tests-overview.md) — + cross-family conventions, architecture diagram, scenario-test pattern. +- [`Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Telegram/TestTelegramBotClientBuilder.cs`](../../../Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Telegram/TestTelegramBotClientBuilder.cs) — + `ITelegramBotClient` substitute builder with optional update-sequence + configuration for polling. +- [`Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestWebApplicationFactory.cs`](../../../Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestWebApplicationFactory.cs) — + generic test host wrapper with optional `TelegramBotClientStub` / + `CommunicationServiceClientStub` init properties. diff --git a/README.md b/README.md index 8a8e6850..f050b977 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,18 @@ You can install all dependencies using NuGet package manager. You can read full instruction in project [Wiki](https://github.com/Vasar007/ProjectV/wiki/Set-up-project). +### EF Core migrations (development) + +Before running `dotnet ef migrations add` (or any other EF Core CLI command), set the +`DatabaseOptions__ConnectionString` environment variable to a valid Npgsql connection string: + +```bash +export DatabaseOptions__ConnectionString="Host=localhost;Port=5432;Database=ProjectV;Username=postgres;Password=postgres" +``` + +The design-time factory (`ProjectVDbContextDesignTimeFactory`) throws `InvalidOperationException` +when this variable is unset — there is no hardcoded fallback. + ## License information This project is licensed under the terms of the [Apache License 2.0](LICENSE). diff --git a/Sources/Directory.Packages.props b/Sources/Directory.Packages.props index 71e06e59..af861fe2 100644 --- a/Sources/Directory.Packages.props +++ b/Sources/Directory.Packages.props @@ -3,6 +3,8 @@ + + @@ -21,7 +23,9 @@ + + @@ -32,6 +36,7 @@ + @@ -53,8 +58,10 @@ + + diff --git a/Sources/Libraries/ExternalServices/ProjectV.OmdbService/ProjectV.OmdbService.csproj b/Sources/Libraries/ExternalServices/ProjectV.OmdbService/ProjectV.OmdbService.csproj index 3e821108..a204ff51 100644 --- a/Sources/Libraries/ExternalServices/ProjectV.OmdbService/ProjectV.OmdbService.csproj +++ b/Sources/Libraries/ExternalServices/ProjectV.OmdbService/ProjectV.OmdbService.csproj @@ -11,6 +11,12 @@ false + + + <_Parameter1>ProjectV.OmdbService.Tests + + + diff --git a/Sources/Libraries/ExternalServices/ProjectV.SteamService/ProjectV.SteamService.csproj b/Sources/Libraries/ExternalServices/ProjectV.SteamService/ProjectV.SteamService.csproj index 870ddddc..f6cd6af7 100644 --- a/Sources/Libraries/ExternalServices/ProjectV.SteamService/ProjectV.SteamService.csproj +++ b/Sources/Libraries/ExternalServices/ProjectV.SteamService/ProjectV.SteamService.csproj @@ -11,6 +11,12 @@ false + + + <_Parameter1>ProjectV.SteamService.Tests + + + diff --git a/Sources/Libraries/ExternalServices/ProjectV.TmdbService/ProjectV.TmdbService.csproj b/Sources/Libraries/ExternalServices/ProjectV.TmdbService/ProjectV.TmdbService.csproj index 9bd18461..439a682a 100644 --- a/Sources/Libraries/ExternalServices/ProjectV.TmdbService/ProjectV.TmdbService.csproj +++ b/Sources/Libraries/ExternalServices/ProjectV.TmdbService/ProjectV.TmdbService.csproj @@ -11,6 +11,12 @@ false + + + <_Parameter1>ProjectV.TmdbService.Tests + + + diff --git a/Sources/Libraries/ProjectV.DataAccessLayer/DataAccessLayerMapper.cs b/Sources/Libraries/ProjectV.DataAccessLayer/DataAccessLayerMapper.cs index 68f39b73..061229bd 100644 --- a/Sources/Libraries/ProjectV.DataAccessLayer/DataAccessLayerMapper.cs +++ b/Sources/Libraries/ProjectV.DataAccessLayer/DataAccessLayerMapper.cs @@ -11,7 +11,7 @@ namespace ProjectV.DataAccessLayer { /// /// Compile-time source-generated mapper (Riok.Mapperly) for the data-access layer. - /// Replaces the AutoMapper DataAccessLayerMapperProfile that was removed in Plan 01-12. + /// Replaces the AutoMapper DataAccessLayerMapperProfile that was removed when AutoMapper was dropped. /// All mapping methods are generated at compile time — zero runtime reflection. /// [Mapper] diff --git a/Sources/Libraries/ProjectV.DataAccessLayer/Migrations/.gitkeep b/Sources/Libraries/ProjectV.DataAccessLayer/Migrations/.gitkeep new file mode 100644 index 00000000..cd216c6f --- /dev/null +++ b/Sources/Libraries/ProjectV.DataAccessLayer/Migrations/.gitkeep @@ -0,0 +1,28 @@ +Reserved for EF Core migrations. + +The initial migration generation was attempted via: + + Platform=x64 \ + DatabaseOptions__CanUseDatabase=true \ + DatabaseOptions__ConnectionString="…" \ + dotnet ef migrations add InitialCreate \ + --project Sources/Libraries/ProjectV.DataAccessLayer \ + --startup-project Sources/WebServices/ProjectV.ProcessingWebService \ + --context ProjectVDbContext \ + --output-dir Migrations \ + --framework net10.0 \ + --no-build \ + --configuration Debug + +The attempt FAILED at design-time model discovery. UserDbInfo's constructor +binds a navigation parameter (`RefreshTokenDbInfo? refreshToken`) that EF +Core cannot resolve to a mapped scalar; auto-discovery rejects every ctor +as unbindable. Repairing this requires architectural changes (a parameterless +ctor on UserDbInfo, an explicit HasOne/WithOne relationship on the +navigation, or a mapper-only nav property without ctor binding) — out of +scope at the time of writing. + +The documented fallback: ProjectV.DataAccessLayer.Tests' DbCollectionFixture +seeds the Postgres test container via raw SQL CREATE TABLE statements derived +from the [Table]/[Column] attributes on JobDbInfo, UserDbInfo, +RefreshTokenDbInfo. EF Core schema bootstrap is deferred. diff --git a/Sources/Libraries/ProjectV.DataAccessLayer/ProjectV.DataAccessLayer.csproj b/Sources/Libraries/ProjectV.DataAccessLayer/ProjectV.DataAccessLayer.csproj index 6e412705..51df1880 100644 --- a/Sources/Libraries/ProjectV.DataAccessLayer/ProjectV.DataAccessLayer.csproj +++ b/Sources/Libraries/ProjectV.DataAccessLayer/ProjectV.DataAccessLayer.csproj @@ -14,6 +14,7 @@ + diff --git a/Sources/Libraries/ProjectV.DataAccessLayer/ProjectVDbContextDesignTimeFactory.cs b/Sources/Libraries/ProjectV.DataAccessLayer/ProjectVDbContextDesignTimeFactory.cs new file mode 100644 index 00000000..07b9ae98 --- /dev/null +++ b/Sources/Libraries/ProjectV.DataAccessLayer/ProjectVDbContextDesignTimeFactory.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Design; + +namespace ProjectV.DataAccessLayer +{ + /// + /// Design-time factory used by dotnet ef tools (e.g. when running + /// migrations add) to construct a + /// without going through the application host. EF Core's design-time + /// service provider cannot disambiguate between + /// and + /// the IOptions<DatabaseOptions> overload, so an explicit + /// factory is required. + /// + /// + /// Pulls connection details from the DatabaseOptions__ConnectionString + /// and DatabaseOptions__CanUseDatabase environment variables. The + /// design-time factory is invoked ONLY by the EF Core CLI; runtime DI + /// continues to use the + /// overload registered via services.AddDbContext<ProjectVDbContext>() + /// in ProjectV.ProcessingWebService.Startup. + /// + public sealed class ProjectVDbContextDesignTimeFactory + : IDesignTimeDbContextFactory + { + /// + public ProjectVDbContext CreateDbContext(string[] args) + { + string connectionString = + Environment.GetEnvironmentVariable("DatabaseOptions__ConnectionString") + ?? throw new InvalidOperationException( + "Cannot resolve the PostgreSQL connection string required by the EF Core " + + "design-time factory. Set the DatabaseOptions__ConnectionString environment " + + "variable to a valid Npgsql connection string before running " + + "`dotnet ef migrations add` (or any other EF Core CLI command). " + + "Example: " + + "DatabaseOptions__ConnectionString=" + + "'Host=localhost;Port=5432;Database=ProjectV;Username=postgres;Password=postgres'"); + + // CanUseDatabase MUST be true for the design-time factory; otherwise + // ProjectVDbContext.OnConfiguring + OnModelCreating short-circuit and + // the generated migration would be empty. + var options = new DatabaseOptions( + dbConnectionString: connectionString, + canUseDatabase: true + ); + + return new ProjectVDbContext(options); + } + } +} diff --git a/Sources/Libraries/ProjectV.DataAccessLayer/Services/Tokens/DatabaseRefreshTokenInfoService.cs b/Sources/Libraries/ProjectV.DataAccessLayer/Services/Tokens/DatabaseRefreshTokenInfoService.cs index c3547845..f30d2221 100644 --- a/Sources/Libraries/ProjectV.DataAccessLayer/Services/Tokens/DatabaseRefreshTokenInfoService.cs +++ b/Sources/Libraries/ProjectV.DataAccessLayer/Services/Tokens/DatabaseRefreshTokenInfoService.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using Acolyte.Assertions; using Microsoft.EntityFrameworkCore; using ProjectV.DataAccessLayer.Services.Basic; @@ -56,9 +57,15 @@ async ValueTask AddTokenAsync(DbSet dbSet) public async Task FindByUserIdAsync(UserId userId) { + // EF Core cannot translate `token.WrappedUserId == userId` — even + // though WrappedUserId is now a computed property, EF cannot lift + // a static-method call (`Users.UserId.Wrap`) or a record-struct + // comparison into SQL. Compare against the raw Guid scalar column + // directly. + Guid rawUserId = userId.Value; RefreshTokenDbInfo? tokenDbModel = await _context.ExecuteIfCanUseDb( () => _context.GetTokenDbSet(), - dbSet => dbSet.FirstOrDefaultAsync(token => token.WrappedUserId == userId) + dbSet => dbSet.FirstOrDefaultAsync(token => token.UserId == rawUserId) ); return tokenDbModel is null ? null : _mapper.MapToRefreshTokenInfo(tokenDbModel); diff --git a/Sources/Libraries/ProjectV.DataAccessLayer/Services/Tokens/Models/RefreshTokenDbInfo.cs b/Sources/Libraries/ProjectV.DataAccessLayer/Services/Tokens/Models/RefreshTokenDbInfo.cs index 9bccd407..df95f174 100644 --- a/Sources/Libraries/ProjectV.DataAccessLayer/Services/Tokens/Models/RefreshTokenDbInfo.cs +++ b/Sources/Libraries/ProjectV.DataAccessLayer/Services/Tokens/Models/RefreshTokenDbInfo.cs @@ -20,7 +20,13 @@ public sealed class RefreshTokenDbInfo [Required] [Column("user_name")] internal Guid UserId { get; } - public UserId WrappedUserId { get; } + + // The Guid property `UserId` above shadows the `UserId` value-object + // type for name lookup inside this class. Use the fully qualified + // namespace because the unqualified `Users` token resolves to the + // sibling DAL namespace `ProjectV.DataAccessLayer.Services.Users` + // here, not to `ProjectV.Models.Users`. + public UserId WrappedUserId => ProjectV.Models.Users.UserId.Wrap(UserId); [Required] [Column("token_hash")] @@ -39,6 +45,30 @@ public sealed class RefreshTokenDbInfo public DateTime ExpiryDate { get; } + // EF Core uses this constructor for entity materialization via + // parameter-name matching against mapped property names (case- + // insensitive) — not column names. The `ThrowIfEmpty` and + // `ThrowIfNullOrWhiteSpace` guards therefore fire both on writes + // (domain code constructing a new token) and on reads (EF + // materializing a row from the `tokens` table). A row with + // `Id = Guid.Empty`, `UserId = Guid.Empty`, or null/whitespace + // `TokenHash`/`TokenSalt` will throw at query-execution time rather + // than being returned as a domain object — this ctor is the + // EF-materialization-path enforcement of those invariants (no + // DB-level CHECK constraint exists; the parallel + // `RefreshTokenInfo` domain model carries its own value-object and + // null guards on the write path). `Ts` and `ExpiryDate` carry no + // ctor-level guard; temporal invariants (e.g. `ExpiryDate < Ts`) + // are not checked here. + // + // Operationally: service-layer reads and deletes route through + // `FindByIdAsync` (the latter via `DeleteAsync`), so a corrupt row + // in the DB cannot be read or deleted through the service. Updates + // are protected by the domain-layer input `UpdateAsync` receives + // (not by a fetch), so a corrupt-in-DB row likewise cannot be + // overwritten by a service call without first being read. + // Data-repair scenarios that need to touch such rows must fix + // them in SQL first. public RefreshTokenDbInfo( Guid id, Guid userId, @@ -48,7 +78,7 @@ public RefreshTokenDbInfo( DateTime expiryDate) { Id = id.ThrowIfEmpty(nameof(id)); - UserId = userId; + UserId = userId.ThrowIfEmpty(nameof(userId)); TokenHash = tokenHash.ThrowIfNullOrWhiteSpace(nameof(tokenHash)); TokenSalt = tokenSalt.ThrowIfNullOrWhiteSpace(nameof(tokenSalt)); Ts = ts; diff --git a/Sources/Libraries/ProjectV.DataAccessLayer/Services/Users/DatabaseUserInfoService.cs b/Sources/Libraries/ProjectV.DataAccessLayer/Services/Users/DatabaseUserInfoService.cs index f7058364..ce41bb31 100644 --- a/Sources/Libraries/ProjectV.DataAccessLayer/Services/Users/DatabaseUserInfoService.cs +++ b/Sources/Libraries/ProjectV.DataAccessLayer/Services/Users/DatabaseUserInfoService.cs @@ -56,9 +56,16 @@ async ValueTask AddUserAsync(DbSet dbSet) public async Task FindByUserNameAsync(UserName userName) { + // EF Core cannot translate `user.WrappedUserName == userName` — + // WrappedUserName is a computed property (UserName.Wrap(UserName)) + // and the underlying scalar `UserName` field is internal. Compare + // against the raw string column directly; the SUT input is the + // domain `UserName` so we read its .Value to get the raw string + // that EF Core can translate into SQL. + string rawUserName = userName.Value; UserDbInfo? userDbModel = await _context.ExecuteIfCanUseDb( () => _context.GetUserDbSet(), - dbSet => dbSet.SingleOrDefaultAsync(user => user.WrappedUserName == userName) + dbSet => dbSet.SingleOrDefaultAsync(user => user.UserName == rawUserName) ); return userDbModel is null ? null : _mapper.MapToUserInfo(userDbModel); diff --git a/Sources/Libraries/ProjectV.DataAccessLayer/Services/Users/Models/UserDbInfo.cs b/Sources/Libraries/ProjectV.DataAccessLayer/Services/Users/Models/UserDbInfo.cs index ffd646b7..6b3cc9e4 100644 --- a/Sources/Libraries/ProjectV.DataAccessLayer/Services/Users/Models/UserDbInfo.cs +++ b/Sources/Libraries/ProjectV.DataAccessLayer/Services/Users/Models/UserDbInfo.cs @@ -38,6 +38,19 @@ public sealed class UserDbInfo [Column("active")] public bool Active { get; } + /// + /// Out-of-band navigation surface for . + /// Marked because the live refresh + /// token row lives in the separate tokens table (configured by + /// via + /// RefreshTokenDbInfo.UserId); EF Core cannot map a navigation + /// type through this immutable property and the previous + /// builder.Property(e => e.RefreshToken) mapping blocked + /// model validation. The mapper hydrates this property out-of-band + /// when needed. See DatabaseUserInfoService.FindByUserNameAsync + /// for the EF-translatable lookup that avoids this property. + /// + [NotMapped] public RefreshTokenDbInfo? RefreshToken { get; } //public ICollection? Tasks { get; } @@ -51,14 +64,30 @@ public UserDbInfo( DateTime ts, bool active, RefreshTokenDbInfo? refreshToken) + : this(id, userName, password, passwordSalt, ts, active) + { + RefreshToken = refreshToken; + } + + // EF Core constructor (no navigation parameter). EF picks the ctor + // whose every argument binds to a mapped scalar; the 7-arg ctor above + // cannot be bound because [NotMapped] excludes RefreshToken from the + // model. This 6-arg overload is the EF-friendly path; production + // callers continue to use the 7-arg ctor via DataAccessLayerMapper. + internal UserDbInfo( + Guid id, + string userName, + string password, + string passwordSalt, + DateTime ts, + bool active) { Id = id.ThrowIfEmpty(nameof(id)); UserName = userName.ThrowIfNullOrWhiteSpace(nameof(userName)); - Password = password.ThrowIfNullOrWhiteSpace(nameof(userName)); - PasswordSalt = passwordSalt.ThrowIfNullOrWhiteSpace(nameof(userName)); + Password = password.ThrowIfNullOrWhiteSpace(nameof(password)); + PasswordSalt = passwordSalt.ThrowIfNullOrWhiteSpace(nameof(passwordSalt)); Ts = ts; Active = active; - RefreshToken = refreshToken; } } @@ -79,7 +108,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.PasswordSalt); builder.Property(e => e.Ts); builder.Property(e => e.Active); - builder.Property(e => e.RefreshToken); + // RefreshToken is [NotMapped] on the entity — see the property + // remark on UserDbInfo. The live refresh token row lives in the + // separate `tokens` table. } #endregion diff --git a/Sources/Libraries/ProjectV.Logging/NLog.config b/Sources/Libraries/ProjectV.Logging/NLog.config index 021d0036..eef3689d 100644 --- a/Sources/Libraries/ProjectV.Logging/NLog.config +++ b/Sources/Libraries/ProjectV.Logging/NLog.config @@ -28,7 +28,6 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/Tests/Fixtures/Omdb/movie-by-title-not-found.json b/Sources/Tests/Fixtures/Omdb/movie-by-title-not-found.json new file mode 100644 index 00000000..8b71e7c4 --- /dev/null +++ b/Sources/Tests/Fixtures/Omdb/movie-by-title-not-found.json @@ -0,0 +1,4 @@ +{ + "Response": "False", + "Error": "Movie not found!" +} diff --git a/Sources/Tests/Fixtures/Omdb/movie-by-title-success.json b/Sources/Tests/Fixtures/Omdb/movie-by-title-success.json new file mode 100644 index 00000000..2e4d801c --- /dev/null +++ b/Sources/Tests/Fixtures/Omdb/movie-by-title-success.json @@ -0,0 +1,29 @@ +{ + "Title": "Synthetic Movie", + "Year": "2020", + "Rated": "PG-13", + "Released": "15 Jan 2020", + "Runtime": "120 min", + "Genre": "Action, Adventure", + "Director": "Synthetic Director", + "Writer": "Synthetic Writer", + "Actors": "Actor A, Actor B, Actor C", + "Plot": "A synthetic movie plot used for ProjectV contract tests. No real production data.", + "Language": "English", + "Country": "USA", + "Awards": "N/A", + "Poster": "https://example.test/poster.jpg", + "Ratings": [ + { "Source": "Internet Movie Database", "Value": "7.5/10" } + ], + "Metascore": "75", + "ImdbRating": "7.5", + "ImdbVotes": "9,876", + "ImdbId": "tt0012345", + "Type": "movie", + "Dvd": "N/A", + "BoxOffice": "N/A", + "Production": "N/A", + "Website": "N/A", + "Response": "True" +} diff --git a/Sources/Tests/Fixtures/Steam/app-detail-success.json b/Sources/Tests/Fixtures/Steam/app-detail-success.json new file mode 100644 index 00000000..30287fc9 --- /dev/null +++ b/Sources/Tests/Fixtures/Steam/app-detail-success.json @@ -0,0 +1,30 @@ +{ + "730": { + "success": true, + "data": { + "type": "game", + "name": "Synthetic Game Alpha", + "steam_appid": 730, + "required_age": 0, + "is_free": true, + "short_description": "A synthetic game description used for ProjectV contract tests. No real production data.", + "header_image": "https://example.test/header.jpg", + "price_overview": { + "currency": "USD", + "initial": 0, + "final": 0, + "discount_percent": 0, + "initial_formatted": "", + "final_formatted": "Free" + }, + "genres": [ + { "id": "1", "description": "Action" }, + { "id": "2", "description": "Adventure" } + ], + "release_date": { + "coming_soon": false, + "date": "21 Aug 2012" + } + } + } +} diff --git a/Sources/Tests/Fixtures/Steam/app-list-success.json b/Sources/Tests/Fixtures/Steam/app-list-success.json new file mode 100644 index 00000000..82c187a5 --- /dev/null +++ b/Sources/Tests/Fixtures/Steam/app-list-success.json @@ -0,0 +1,9 @@ +{ + "applist": { + "apps": [ + { "appid": 730, "name": "Synthetic Game Alpha" }, + { "appid": 570, "name": "Synthetic Game Beta" }, + { "appid": 440, "name": "Synthetic Game Gamma" } + ] + } +} diff --git a/Sources/Tests/Fixtures/Tmdb/configuration-success.json b/Sources/Tests/Fixtures/Tmdb/configuration-success.json new file mode 100644 index 00000000..5203f9f9 --- /dev/null +++ b/Sources/Tests/Fixtures/Tmdb/configuration-success.json @@ -0,0 +1,17 @@ +{ + "images": { + "base_url": "http://image.example.test/t/p/", + "secure_base_url": "https://image.example.test/t/p/", + "backdrop_sizes": ["w300", "w780", "original"], + "logo_sizes": ["w45", "w92", "w154"], + "poster_sizes": ["w92", "w154", "w185", "w342", "original"], + "profile_sizes": ["w45", "h632", "original"], + "still_sizes": ["w92", "w185", "original"] + }, + "change_keys": [ + "adult", + "title", + "overview", + "release_date" + ] +} diff --git a/Sources/Tests/Fixtures/Tmdb/search-movie-empty.json b/Sources/Tests/Fixtures/Tmdb/search-movie-empty.json new file mode 100644 index 00000000..3eab1791 --- /dev/null +++ b/Sources/Tests/Fixtures/Tmdb/search-movie-empty.json @@ -0,0 +1,6 @@ +{ + "page": 1, + "results": [], + "total_pages": 0, + "total_results": 0 +} diff --git a/Sources/Tests/Fixtures/Tmdb/search-movie-success.json b/Sources/Tests/Fixtures/Tmdb/search-movie-success.json new file mode 100644 index 00000000..8d363f7b --- /dev/null +++ b/Sources/Tests/Fixtures/Tmdb/search-movie-success.json @@ -0,0 +1,23 @@ +{ + "page": 1, + "results": [ + { + "adult": false, + "backdrop_path": "/synthetic-backdrop.jpg", + "genre_ids": [28, 12], + "id": 12345, + "original_language": "en", + "original_title": "Synthetic Movie", + "overview": "A synthetic movie overview used for ProjectV contract tests. No real production data.", + "popularity": 12.345, + "poster_path": "/synthetic-poster.jpg", + "release_date": "2020-01-15", + "title": "Synthetic Movie", + "video": false, + "vote_average": 7.5, + "vote_count": 9876 + } + ], + "total_pages": 1, + "total_results": 1 +} diff --git a/Sources/Tests/ProjectV.Appraisers.Tests/AppraiserTests.cs b/Sources/Tests/ProjectV.Appraisers.Tests/AppraiserTests.cs index aad4691f..8731c543 100644 --- a/Sources/Tests/ProjectV.Appraisers.Tests/AppraiserTests.cs +++ b/Sources/Tests/ProjectV.Appraisers.Tests/AppraiserTests.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using AwesomeAssertions; using ProjectV.Models.Data; using ProjectV.Models.Internal; using Xunit; namespace ProjectV.Appraisers.Tests { + [Trait("Category", "Unit")] public sealed class AppraiserTests { public AppraiserTests() @@ -16,101 +18,110 @@ public AppraiserTests() [Fact] public void CheckTagPropertyDefaultValue() { + // Arrange. var appraiser = TestAppraisersCreator.CreateBasicAppraiser(); + string expectedValue = $"Appraiser<{nameof(BasicInfo)}>"; + // Act. string actualValue = appraiser.Tag; - string expectedValue = $"Appraiser<{nameof(BasicInfo)}>"; - - Assert.NotNull(actualValue); - Assert.NotEmpty(actualValue); - Assert.Equal(expectedValue, actualValue); + // Assert. + actualValue.Should().NotBeNull(); + actualValue.Should().NotBeEmpty(); + actualValue.Should().Be(expectedValue); } [Fact] public void CheckTypeIdPropertyDefaultValue() { + // Arrange. var appraiser = TestAppraisersCreator.CreateBasicAppraiser(); + Type expectedValue = typeof(BasicInfo); + // Act. Type actualValue = appraiser.TypeId; - Type expectedValue = typeof(BasicInfo); - - Assert.NotNull(actualValue); - Assert.Equal(expectedValue, actualValue); + // Assert. + actualValue.Should().NotBeNull(); + actualValue.Should().Be(expectedValue); } [Fact] public void CheckRatingNamePropertyDefaultValue() { + // Arrange. var appraiser = TestAppraisersCreator.CreateBasicAppraiser(); + const string expectedValue = "Common rating"; + // Act. string actualValue = appraiser.RatingName; - const string expectedValue = "Common rating"; - - Assert.NotNull(actualValue); - Assert.NotEmpty(actualValue); - Assert.Equal(expectedValue, actualValue); + // Assert. + actualValue.Should().NotBeNull(); + actualValue.Should().NotBeEmpty(); + actualValue.Should().Be(expectedValue); } [Fact] public void GetRatingsThrowsExceptionBecauseOfNullParameter() { + // Arrange. var appraiser = TestAppraisersCreator.CreateBasicAppraiser(); - Assert.Throws( - "entityInfo", -#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. - () => appraiser.GetRatings(entityInfo: null, outputResults: false) -#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. - ); - Assert.Throws( - "entityInfo", -#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. - () => appraiser.GetRatings(entityInfo: null, outputResults: true) -#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. - ); + // Act. / Assert. + var actWithoutOutput = () => appraiser.GetRatings(entityInfo: null!, outputResults: false); + actWithoutOutput.Should() + .Throw() + .WithParameterName("entityInfo"); + + var actWithOutput = () => appraiser.GetRatings(entityInfo: null!, outputResults: true); + actWithOutput.Should() + .Throw() + .WithParameterName("entityInfo"); } [Fact] public void CallGetRatingsWithConteinerWithOneItem() { + // Arrange. var appraiser = TestAppraisersCreator.CreateBasicAppraiser(); - Guid ratingId = Guid.Empty; - var item = new BasicInfo( thingId: 1, title: "Title", voteCount: 10, voteAverage: 9.9 ); + var expectedValue = TestDataCreator + .CreateExpectedValueForBasicInfo(ratingId, item) + .Single(); + // Act. var actualValue = appraiser.GetRatings(item, outputResults: false); - var expectedValue = TestDataCreator.CreateExpectedValueForBasicInfo(ratingId, item) - .Single(); - - Assert.NotNull(actualValue); - Assert.Equal(expectedValue, actualValue); + // Assert. + actualValue.Should().NotBeNull(); + actualValue.Should().Be(expectedValue); } [Fact] public void CallGetRatingsWithConteinerWithThreeItems() { + // Arrange. var appraiser = TestAppraisersCreator.CreateBasicAppraiser(); - Guid ratingId = Guid.Empty; - var item1 = new BasicInfo( thingId: 1, title: "Title-1", voteCount: 11, voteAverage: 9.7 ); var item2 = new BasicInfo( - thingId: 2, title: "Title-2", voteCount: 12, voteAverage: 9.8 - ); + thingId: 2, title: "Title-2", voteCount: 12, voteAverage: 9.8 + ); var item3 = new BasicInfo( - thingId: 3, title: "Title-3", voteCount: 13, voteAverage: 9.9 - ); + thingId: 3, title: "Title-3", voteCount: 13, voteAverage: 9.9 + ); var items = new[] { item1, item2, item3 }; + var expectedValue = TestDataCreator.CreateExpectedValueForBasicInfo( + ratingId, item1, item2, item3 + ); + // Act. var actualValue = new List(); for (int index = 0; index < items.Length; ++index) { @@ -118,13 +129,10 @@ public void CallGetRatingsWithConteinerWithThreeItems() actualValue.Add(actualRating); } - var expectedValue = TestDataCreator.CreateExpectedValueForBasicInfo( - ratingId, item1, item2, item3 - ); - - Assert.NotNull(actualValue); - Assert.NotEmpty(actualValue); - Assert.Equal(expectedValue, actualValue); + // Assert. + actualValue.Should().NotBeNull(); + actualValue.Should().NotBeEmpty(); + actualValue.Should().BeEquivalentTo(expectedValue); } [Theory] @@ -138,12 +146,13 @@ public void CallGetRatingsWithConteinerWithThreeItems() [InlineData(100)] public void CallGetRatingsWithConteinerWithRandomData(int itemsCount) { + // Arrange. var appraiser = TestAppraisersCreator.CreateBasicAppraiser(); - Guid ratingId = Guid.Empty; - var items = TestDataCreator.CreateBasicInfoListRandomly(itemsCount); + var expectedValue = TestDataCreator.CreateExpectedValueForBasicInfo(ratingId, items); + // Act. var actualValue = new List(); for (int index = 0; index < items.Count; ++index) { @@ -151,11 +160,10 @@ public void CallGetRatingsWithConteinerWithRandomData(int itemsCount) actualValue.Add(actualRating); } - var expectedValue = TestDataCreator.CreateExpectedValueForBasicInfo(ratingId, items); - - Assert.NotNull(actualValue); - Assert.NotEmpty(actualValue); - Assert.Equal(expectedValue, actualValue); + // Assert. + actualValue.Should().NotBeNull(); + actualValue.Should().NotBeEmpty(); + actualValue.Should().BeEquivalentTo(expectedValue); } } } diff --git a/Sources/Tests/ProjectV.Appraisers.Tests/AppraisersExtensions/AppraisersManagerTests.cs b/Sources/Tests/ProjectV.Appraisers.Tests/AppraisersExtensions/AppraisersManagerTests.cs new file mode 100644 index 00000000..90381ebd --- /dev/null +++ b/Sources/Tests/ProjectV.Appraisers.Tests/AppraisersExtensions/AppraisersManagerTests.cs @@ -0,0 +1,217 @@ +using System; +using Acolyte.Common.Monads; +using AwesomeAssertions; +using NSubstitute; +using ProjectV.DataPipeline; +using ProjectV.Models.Data; +using ProjectV.Models.Internal; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Mocks.Appraisers; +using ProjectV.Tests.Shared.Helpers.Stubs.Appraisers; +using Xunit; + +namespace ProjectV.Appraisers.Tests.AppraisersExtensions +{ + /// + /// Unit tests for — verifies the + /// Add/Remove API, the shape, + /// and the contract surface around children. + /// Uses to construct the SUT + /// and for substitute children. + /// + [Trait("Category", "Unit")] + public sealed class AppraisersManagerTests : BaseMockTest + { + public AppraisersManagerTests() + { + } + + [Fact] + public void CreateWithoutSetupReturnsEmptyManager() + { + // Arrange. / Act. + var sut = TestAppraisersManagerBuilder.CreateWithoutSetup(); + + // Assert. An empty manager produces a non-null but childless flow. + sut.Should().NotBeNull(); + var flow = sut.CreateFlow(); + flow.Should().NotBeNull(); + flow.Should().BeOfType(); + } + + [Fact] + public void AddThrowsForNullAppraiser() + { + // Arrange. + var sut = TestAppraisersManagerBuilder.CreateWithoutSetup(); + + // Act. + var act = () => + { + sut.Add(item: null!); + }; + + // Assert. + act.Should().Throw() + .WithParameterName("item"); + } + + [Fact] + public void RemoveThrowsForNullAppraiser() + { + // Arrange. + var sut = TestAppraisersManagerBuilder.CreateWithoutSetup(); + + // Act. + var act = () => + { + sut.Remove(item: null!); + }; + + // Assert. + act.Should().Throw() + .WithParameterName("item"); + } + + [Fact] + public void AddOnceRegistersAppraiserUnderItsTypeId() + { + // Arrange. + var appraiser = CreateAppraiser(typeof(BasicInfo), "tag"); + var sut = new TestAppraisersManagerBuilder() + .WithAppraiser(appraiser) + .Build(); + + // Act. + var flow = sut.CreateFlow(); + + // Assert. The flow construction reads TypeId from every child. + _ = appraiser.Received().TypeId; + flow.Should().NotBeNull(); + } + + [Fact] + public void AddSameInstanceTwiceIsIdempotentWithinSameTypeId() + { + // Arrange. + var appraiser = CreateAppraiser(typeof(BasicInfo), "tag"); + var sut = TestAppraisersManagerBuilder.CreateWithoutSetup(); + + // Act. + sut.Add(appraiser); + sut.Add(appraiser); + + // Assert. Production AppraisersManager.Add (see Sources/Libraries/ + // ProjectV.Appraisers/AppraisersManager.cs) skips a duplicate + // reference in the same TypeId bucket — the flow must still + // build, and Remove(item) returning true confirms the bucket + // exists with the registered child. + sut.Remove(appraiser).Should().BeTrue(); + } + + [Fact] + public void AddTwoDifferentInstancesOfSameTypeIdBuildsCombinedFlow() + { + // Arrange. + var first = CreateAppraiser(typeof(BasicInfo), "first"); + var second = CreateAppraiser(typeof(BasicInfo), "second"); + var sut = new TestAppraisersManagerBuilder() + .WithAppraiser(first) + .WithAppraiser(second) + .Build(); + + // Act. + var flow = sut.CreateFlow(); + + // Assert. + flow.Should().NotBeNull(); + flow.Should().BeOfType(); + } + + [Fact] + public void RemoveExistingReturnsTrue() + { + // Arrange. + var appraiser = CreateAppraiser(typeof(BasicInfo), "tag"); + var sut = new TestAppraisersManagerBuilder() + .WithAppraiser(appraiser) + .Build(); + + // Act. + var removed = sut.Remove(appraiser); + + // Assert. + removed.Should().BeTrue(); + } + + [Fact] + public void RemoveMissingReturnsFalse() + { + // Arrange. + var sut = TestAppraisersManagerBuilder.CreateWithoutSetup(); + var appraiser = CreateAppraiser(typeof(BasicInfo), "tag"); + + // Act. + var removed = sut.Remove(appraiser); + + // Assert. + removed.Should().BeFalse(); + } + + [Fact] + public void CreateFlowDispatchesEntitiesToMatchingChildAppraiser() + { + // Arrange. + var expectedRating = new RatingDataContainer( + dataHandler: new BasicInfo( + thingId: 99, title: "Dispatch", voteCount: 1, voteAverage: 1.0), + ratingValue: 7.5, + ratingId: Guid.Empty); + + var basicAppraiser = CreateAppraiser(typeof(BasicInfo), "tag", expectedRating); + + var sut = new TestAppraisersManagerBuilder() + .WithAppraiser(basicAppraiser) + .Build(); + + // Act. + var flow = sut.CreateFlow(); + + // Assert. The flow is constructed from a single bucket; the + // SUT's wiring exercises TypeId on every child during the + // CreateFlow walk (see AppraisersManager.CreateFlow). + flow.Should().NotBeNull(); + _ = basicAppraiser.Received().TypeId; + } + + [Fact] + public void CreateFlowReturnsDistinctInstancesAcrossCalls() + { + // Arrange. + var appraiser = CreateAppraiser(typeof(BasicInfo), "tag"); + var sut = new TestAppraisersManagerBuilder() + .WithAppraiser(appraiser) + .Build(); + + // Act. + var firstFlow = sut.CreateFlow(); + var secondFlow = sut.CreateFlow(); + + // Assert. + firstFlow.Should().NotBeSameAs(secondFlow); + } + + #region Helper Methods + + private IAppraiser CreateAppraiser(Type typeId, string tag, RatingDataContainer? rating = null) + { + return new TestAppraiserBuilder(Fixture) + .WithTypeId(typeId) + .WithTag(tag) + .ApplyIf(rating is not null, x => x.WithRating(rating!)) + .Build(); + } + + #endregion + } +} diff --git a/Sources/Tests/ProjectV.Appraisers.Tests/AppraisersExtensions/MovieCommonAppraiserTests.cs b/Sources/Tests/ProjectV.Appraisers.Tests/AppraisersExtensions/MovieCommonAppraiserTests.cs new file mode 100644 index 00000000..d072a0f9 --- /dev/null +++ b/Sources/Tests/ProjectV.Appraisers.Tests/AppraisersExtensions/MovieCommonAppraiserTests.cs @@ -0,0 +1,173 @@ +using System; +using AwesomeAssertions; +using ProjectV.Appraisers.Appraisals.Movie.Tmdb; +using ProjectV.Models.Data; +using ProjectV.Tests.Shared.ForTests; +using Xunit; + +namespace ProjectV.Appraisers.Tests.AppraisersExtensions +{ + /// + /// Rating-computation accuracy for the canonical movie-common appraiser + /// path: of + /// composed with , which returns the + /// movie's TMDb popularity as its rating value. + /// + /// + /// The plan named this file MovieCommonAppraiserTests; ProjectV + /// does NOT declare a MovieCommonAppraiser type — the production + /// shape is Appraiser<TmdbMovieInfo>(new TmdbCommonAppraisal()). + /// The unit boundary is the appraiser class composed with its strategy; + /// the strategy is exercised directly (not mocked) because the strategy + /// is the source of the rating value. + /// + [Trait("Category", "Unit")] + public sealed class MovieCommonAppraiserTests : BaseMockTest + { + public MovieCommonAppraiserTests() + { + } + + private static Appraiser CreateSut() + { + return new Appraiser(new TmdbCommonAppraisal()); + } + + private static TmdbMovieInfo CreateMovie(double popularity = 7.5, + double voteAverage = 8.1, int voteCount = 1234) + { + return new TmdbMovieInfo( + thingId: 42, + title: "Inception", + voteCount: voteCount, + voteAverage: voteAverage, + overview: "A heist inside dreams.", + releaseDate: new DateTime(2010, 7, 16), + popularity: popularity, + adult: false, + genreIds: new[] { 28, 878 }, + posterPath: "/inception.jpg" + ); + } + + [Fact] + public void TagPropertyMatchesGenericTypeName() + { + // Arrange. + var sut = CreateSut(); + var expected = $"Appraiser<{nameof(TmdbMovieInfo)}>"; + + // Act. + var actual = sut.Tag; + + // Assert. + actual.Should().NotBeNull(); + actual.Should().NotBeEmpty(); + actual.Should().Be(expected); + } + + [Fact] + public void TypeIdMatchesGenericArgument() + { + // Arrange. + var sut = CreateSut(); + + // Act. + var actual = sut.TypeId; + + // Assert. + actual.Should().Be(typeof(TmdbMovieInfo)); + } + + [Fact] + public void RatingNameMatchesAppraisalRatingName() + { + // Arrange. + var sut = CreateSut(); + const string expected = "Rating based on popularity"; + + // Act. + var actual = sut.RatingName; + + // Assert. + actual.Should().Be(expected); + } + + [Fact] + public void GetRatingsReturnsPopularityFromAppraisal() + { + // Arrange. + var sut = CreateSut(); + var movie = CreateMovie(popularity: 12.34); + + // Act. + var actual = sut.GetRatings(movie, outputResults: false); + + // Assert. + actual.Should().NotBeNull(); + actual.RatingValue.Should().Be(12.34); + actual.DataHandler.Should().BeSameAs(movie); + actual.RatingId.Should().Be(Guid.Empty); + } + + [Fact] + public void GetRatingsAssignsEmptyRatingIdToAllResults() + { + // Arrange. + var sut = CreateSut(); + var first = CreateMovie(popularity: 1.0); + var second = CreateMovie(popularity: 9.9); + + // Act. + var firstRating = sut.GetRatings(first, outputResults: false); + var secondRating = sut.GetRatings(second, outputResults: false); + + // Assert. + firstRating.RatingId.Should().Be(Guid.Empty); + secondRating.RatingId.Should().Be(Guid.Empty); + firstRating.RatingValue.Should().NotBe(secondRating.RatingValue); + } + + [Fact] + public void GetRatingsThrowsForNullEntity() + { + // Arrange. + var sut = CreateSut(); + + // Act. / Assert. + var act = () => + { + sut.GetRatings(entityInfo: null!, outputResults: false); + }; + act.Should().Throw() + .WithParameterName("entityInfo"); + } + + [Fact] + public void GetRatingsThrowsForBaseBasicInfoBecauseAppraiserExpectsTmdb() + { + // Arrange. + var sut = CreateSut(); + var basicInfo = new BasicInfo( + thingId: 1, title: "Generic", voteCount: 10, voteAverage: 7.0); + + // Act. + var act = () => sut.GetRatings(basicInfo, outputResults: false); + + // Assert. + act.Should().Throw(); + } + + [Fact] + public void ConstructorThrowsForNullAppraisal() + { + // Arrange. / Act. / Assert. + var act = () => + { + _ = new Appraiser(appraisal: null!); + }; + act.Should().Throw() + .WithParameterName("appraisal"); + } + } +} diff --git a/Sources/Tests/ProjectV.Appraisers.Tests/AppraisersExtensions/MovieNormalizedAppraiserTests.cs b/Sources/Tests/ProjectV.Appraisers.Tests/AppraisersExtensions/MovieNormalizedAppraiserTests.cs new file mode 100644 index 00000000..aec005b3 --- /dev/null +++ b/Sources/Tests/ProjectV.Appraisers.Tests/AppraisersExtensions/MovieNormalizedAppraiserTests.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using AwesomeAssertions; +using ProjectV.Appraisers.Appraisals; +using ProjectV.Models.Data; +using ProjectV.Models.Internal; +using ProjectV.Tests.Shared.ForTests; +using Xunit; + +namespace ProjectV.Appraisers.Tests.AppraisersExtensions +{ + /// + /// Rating-computation accuracy for the canonical movie-normalized + /// appraiser path: of + /// composed with , which projects + /// vote-count and vote-average onto a min-max normalised scale. + /// + /// + /// The plan named this file MovieNormalizedAppraiserTests; + /// ProjectV does NOT declare a MovieNormalizedAppraiser type — + /// the production shape is + /// Appraiser<BasicInfo>(new BasicAppraisalNormalized()). The + /// appraisal MUST be prepared via + /// with a + /// that carries + /// s keyed by + /// nameof(BasicInfo.VoteCount) and + /// nameof(BasicInfo.VoteAverage); otherwise it throws + /// . + /// + [Trait("Category", "Unit")] + public sealed class MovieNormalizedAppraiserTests : BaseMockTest + { + public MovieNormalizedAppraiserTests() + { + } + + private static BasicAppraisalNormalized CreateAppraisal() + { + return new BasicAppraisalNormalized(); + } + + private static Appraiser CreateSut(BasicAppraisalNormalized appraisal) + { + return new Appraiser(appraisal); + } + + private static IReadOnlyList CreateMovieBatch() + { + return new[] + { + new BasicInfo(thingId: 1, title: "A", voteCount: 10, voteAverage: 5.0), + new BasicInfo(thingId: 2, title: "B", voteCount: 50, voteAverage: 7.0), + new BasicInfo(thingId: 3, title: "C", voteCount: 90, voteAverage: 9.0), + }; + } + + private static RawDataContainer CreateRawDataContainer( + IReadOnlyList items) + { + var container = new RawDataContainer(items); + container.AddParameter( + nameof(BasicInfo.VoteCount), + MinMaxDenominator.CreateForCollection(items, b => b.VoteCount)); + container.AddParameter( + nameof(BasicInfo.VoteAverage), + MinMaxDenominator.CreateForCollection(items, b => b.VoteAverage)); + return container; + } + + [Fact] + public void TagPropertyReflectsBasicInfoTypeParameter() + { + // Arrange. + var sut = CreateSut(CreateAppraisal()); + var expected = $"Appraiser<{nameof(BasicInfo)}>"; + + // Act. + var actual = sut.Tag; + + // Assert. + actual.Should().Be(expected); + } + + [Fact] + public void RatingNameMatchesAppraisalRatingName() + { + // Arrange. + var sut = CreateSut(CreateAppraisal()); + + // Act. + var actual = sut.RatingName; + + // Assert. + actual.Should().Be("Common rating"); + } + + [Fact] + public void GetRatingsAfterPrepareReturnsNormalisedSumOfMinAndMaxItem() + { + // Arrange. + var items = CreateMovieBatch(); + var appraisal = CreateAppraisal(); + appraisal.PrepareCalculation(CreateRawDataContainer(items)); + var sut = CreateSut(appraisal); + + // Act. + var minRating = sut.GetRatings(items[0], outputResults: false); + var maxRating = sut.GetRatings(items[2], outputResults: false); + + // Assert. min-item maps to 0 + 0 = 0; max-item maps to 1 + 1 = 2. + minRating.RatingValue.Should().Be(0.0); + maxRating.RatingValue.Should().Be(2.0); + } + + [Fact] + public void GetRatingsAfterPrepareReturnsBoundedValueForMiddleItem() + { + // Arrange. + var items = CreateMovieBatch(); + var appraisal = CreateAppraisal(); + appraisal.PrepareCalculation(CreateRawDataContainer(items)); + var sut = CreateSut(appraisal); + + // Act. + var middle = sut.GetRatings(items[1], outputResults: false); + + // Assert. Middle item lies inside the [0, 2] envelope produced + // by the min-max normalisation. + middle.RatingValue.Should().BeGreaterThanOrEqualTo(0.0); + middle.RatingValue.Should().BeLessThanOrEqualTo(2.0); + } + + [Fact] + public void GetRatingsWithoutPrepareThrowsInvalidOperation() + { + // Arrange. + var sut = CreateSut(CreateAppraisal()); + var entity = new BasicInfo( + thingId: 1, title: "X", voteCount: 10, voteAverage: 5.0); + + // Act. + var act = () => sut.GetRatings(entity, outputResults: false); + + // Assert. BasicAppraisalNormalized requires PrepareCalculation + // to populate the MinMaxDenominator fields. + act.Should().Throw(); + } + + [Fact] + public void GetRatingsThrowsForNullEntity() + { + // Arrange. + var appraisal = CreateAppraisal(); + appraisal.PrepareCalculation(CreateRawDataContainer(CreateMovieBatch())); + var sut = CreateSut(appraisal); + + // Act. + var act = () => + { + sut.GetRatings(entityInfo: null!, outputResults: false); + }; + + // Assert. + act.Should().Throw() + .WithParameterName("entityInfo"); + } + + [Fact] + public void PrepareCalculationThrowsForNullContainer() + { + // Arrange. + var appraisal = CreateAppraisal(); + + // Act. + var act = () => + { + appraisal.PrepareCalculation(rawDataContainer: null!); + }; + + // Assert. + act.Should().Throw() + .WithParameterName("rawDataContainer"); + } + } +} diff --git a/Sources/Tests/ProjectV.Appraisers.Tests/ProjectV.Appraisers.Tests.csproj b/Sources/Tests/ProjectV.Appraisers.Tests/ProjectV.Appraisers.Tests.csproj index a7479191..45597afd 100644 --- a/Sources/Tests/ProjectV.Appraisers.Tests/ProjectV.Appraisers.Tests.csproj +++ b/Sources/Tests/ProjectV.Appraisers.Tests/ProjectV.Appraisers.Tests.csproj @@ -11,15 +11,16 @@ false - - - - - - + + diff --git a/Sources/Tests/ProjectV.Appraisers.Tests/TestDataCreator.cs b/Sources/Tests/ProjectV.Appraisers.Tests/TestDataCreator.cs index a2f83683..3a49d68a 100644 --- a/Sources/Tests/ProjectV.Appraisers.Tests/TestDataCreator.cs +++ b/Sources/Tests/ProjectV.Appraisers.Tests/TestDataCreator.cs @@ -9,7 +9,11 @@ namespace ProjectV.Appraisers.Tests { internal static class TestDataCreator { - private static Random RandomInstance { get; } = new Random(); + // Seeded with 42 for run-to-run determinism. + // Note: `new Random(seed: 42)` lowercase parameter name does not compile + // under .NET 10 (CS1739) — the constructor parameter is `Seed` (capital S). + // The seed value (42) is preserved. + private static Random RandomInstance { get; } = new Random(Seed: 42); internal static IReadOnlyList CreateExpectedValueForBasicInfo( diff --git a/Sources/Tests/ProjectV.Common.Tests/ModelSerializationTests.cs b/Sources/Tests/ProjectV.Common.Tests/ModelSerializationTests.cs index 004eadbf..2c47fa2f 100644 --- a/Sources/Tests/ProjectV.Common.Tests/ModelSerializationTests.cs +++ b/Sources/Tests/ProjectV.Common.Tests/ModelSerializationTests.cs @@ -1,55 +1,56 @@ -using System.Text.Json; +using AwesomeAssertions; +using Newtonsoft.Json; using ProjectV.Models.Data; using Xunit; namespace ProjectV.Common.Tests { + [Trait("Category", "Unit")] public sealed class ModelSerializationTests { public ModelSerializationTests() { } - [Fact(Skip = "Current version of JsonSerializer cannot work with class without " + - "parameterless constructors.")] + [Fact] public void BasicInfoSerializationToJsonAndBack() { + // Arrange. + // BasicInfo is annotated with [JsonConstructor] on its 4-arg ctor + // (see Sources/Libraries/ProjectV.Models/Data/BasicInfo.cs), so + // Newtonsoft.Json round-trips correctly even without a parameterless + // ctor. This replaces the System.Text.Json approach that required a + // parameterless ctor and was the reason the original test was Skip'd. var expectedModel = new BasicInfo(42, "Title", 100, 9.9); - string json = Serialize(expectedModel); - var actualModel = Deserialize(json); - Assert.NotNull(actualModel); - Assert.Equal(expectedModel, actualModel); + // Act. + string compactJson = Serialize(expectedModel); + var compactRoundTrip = Deserialize(compactJson); - json = SerializePrettyPrint(expectedModel); - actualModel = Deserialize(json); - Assert.NotNull(actualModel); - Assert.Equal(expectedModel, actualModel); + string prettyJson = SerializePrettyPrint(expectedModel); + var prettyRoundTrip = Deserialize(prettyJson); + + // Assert. + compactRoundTrip.Should().NotBeNull(); + compactRoundTrip.Should().Be(expectedModel); + + prettyRoundTrip.Should().NotBeNull(); + prettyRoundTrip.Should().Be(expectedModel); } private static string Serialize(T value) { - return JsonSerializer.Serialize(value); + return JsonConvert.SerializeObject(value); } private static string SerializePrettyPrint(T value) { - var options = new JsonSerializerOptions - { - WriteIndented = true - }; - - return JsonSerializer.Serialize(value, options); + return JsonConvert.SerializeObject(value, Formatting.Indented); } private static T? Deserialize(string json) { - var options = new JsonSerializerOptions - { - AllowTrailingCommas = true - }; - - return JsonSerializer.Deserialize(json, options); + return JsonConvert.DeserializeObject(json); } } } diff --git a/Sources/Tests/ProjectV.Common.Tests/ProjectV.Common.Tests.csproj b/Sources/Tests/ProjectV.Common.Tests/ProjectV.Common.Tests.csproj index c995229b..89aee875 100644 --- a/Sources/Tests/ProjectV.Common.Tests/ProjectV.Common.Tests.csproj +++ b/Sources/Tests/ProjectV.Common.Tests/ProjectV.Common.Tests.csproj @@ -11,16 +11,21 @@ false + + - - - - + + diff --git a/Sources/Tests/ProjectV.CommunicationWebService.Tests/ProjectV.CommunicationWebService.Tests.csproj b/Sources/Tests/ProjectV.CommunicationWebService.Tests/ProjectV.CommunicationWebService.Tests.csproj new file mode 100644 index 00000000..6266c6dd --- /dev/null +++ b/Sources/Tests/ProjectV.CommunicationWebService.Tests/ProjectV.CommunicationWebService.Tests.csproj @@ -0,0 +1,30 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.CommunicationWebService.Tests + false + false + + + + + + + + + + + + + diff --git a/Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtAnonymousRequestTests.cs b/Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtAnonymousRequestTests.cs new file mode 100644 index 00000000..5819e9be --- /dev/null +++ b/Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtAnonymousRequestTests.cs @@ -0,0 +1,52 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using AwesomeAssertions; +using Xunit; + +namespace ProjectV.CommunicationWebService.Tests.Scenarios.Jwt +{ + /// + /// Scenario JWT-1: Anonymous request to /api/v1/Requests is + /// rejected. + /// + /// + /// When no Authorization header is attached to a request that + /// targets POST /api/v1/Requests — a controller action decorated + /// with [Authorize] in + /// ProjectV.CommunicationWebService.v1.Controllers.RequestsController + /// — the production JWT bearer middleware + /// (AddJtwAuthentication in ProjectV.CommonWebApi) must + /// short-circuit the pipeline with HTTP 401 Unauthorized. + /// + [Trait("Category", "Integration")] + public sealed class JwtAnonymousRequestTests : JwtAuthScenarioBaseTest + { + /// + /// Initializes a new instance of the + /// class. + /// + public JwtAnonymousRequestTests() + { + } + + /// + /// Scenario JWT-1 — anonymous POST is rejected with HTTP 401. + /// + [Fact] + public async Task RequestToProtectedEndpoint_WithoutToken_Returns401() + { + // Arrange. + using var content = new StringContent("{}", Encoding.UTF8, "application/json"); + + // Act. + using HttpResponseMessage response = await Client.PostAsync( + "/api/v1/Requests", content); + + // Assert. + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "unauthenticated requests must be rejected with 401"); + } + } +} diff --git a/Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtAuthScenarioBaseTest.cs b/Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtAuthScenarioBaseTest.cs new file mode 100644 index 00000000..030ae2a2 --- /dev/null +++ b/Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtAuthScenarioBaseTest.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.WebApi; + +namespace ProjectV.CommunicationWebService.Tests.Scenarios.Jwt +{ + /// + /// Per-family base class for JWT scenario tests against + /// ProjectV.CommunicationWebService. Bundles the + /// wiring + any + /// JWT-specific configuration overrides that every scenario in the + /// family inherits. + /// + /// + /// The default is shared across the suite, + /// so a single base64 secret signs tokens for every test. + /// UserServiceOptions is left in its appsettings.json + /// baseline (system user is created on startup) — individual scenarios + /// can layer additional in-memory configuration on top by handing + /// extra key-value pairs through the protected constructor. + /// + public abstract class JwtAuthScenarioBaseTest : WebApiBaseTest + { + /// + /// Initializes a new instance of the + /// class with default JWT + /// signing material and no extra configuration overrides. + /// + protected JwtAuthScenarioBaseTest() + : this(extraConfiguration: null, configureTestServices: null) + { + } + + /// + /// Initializes a new instance of the + /// class. + /// + /// + /// Optional in-memory configuration overrides layered on top of the + /// host's appsettings.json — for example a custom + /// UserServiceOptions:SystemUserName / :SystemUserPassword + /// pair for the login round-trip scenario. + /// + /// + /// Optional DI override action that runs AFTER + /// Startup.ConfigureServices. + /// + protected JwtAuthScenarioBaseTest( + IReadOnlyDictionary? extraConfiguration, + Action? configureTestServices) + : base( + jwtConfig: null, + extraConfiguration: extraConfiguration, + configureTestServices: configureTestServices) + { + } + } +} diff --git a/Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtAuthenticatedRequestTests.cs b/Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtAuthenticatedRequestTests.cs new file mode 100644 index 00000000..88ff12f1 --- /dev/null +++ b/Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtAuthenticatedRequestTests.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using AwesomeAssertions; +using Xunit; + +namespace ProjectV.CommunicationWebService.Tests.Scenarios.Jwt +{ + /// + /// Scenario JWT-2: Authenticated request to /api/v1/Requests is + /// accepted by the JWT pipeline. + /// + /// + /// A valid bearer token signed with the same secret / issuer / audience + /// the host was configured with must pass the + /// TokenValidationParameters check in + /// AddJtwAuthentication. The scenario does NOT assert on the + /// response body shape — the request body is intentionally empty so the + /// underlying configuration receiver may reject it with a 400 — what + /// matters is that the response is NOT 401 / 403 (i.e. the auth pipeline + /// let the request through). + /// + [Trait("Category", "Integration")] + public sealed class JwtAuthenticatedRequestTests : JwtAuthScenarioBaseTest + { + /// + /// Initializes a new instance of the + /// class. + /// + public JwtAuthenticatedRequestTests() + { + } + + /// + /// Scenario JWT-2 — authenticated POST passes the JWT bearer + /// pipeline (status is anything except 401 / 403). + /// + [Fact] + public async Task RequestToProtectedEndpoint_WithValidToken_PassesAuthPipeline() + { + // Arrange. + using HttpClient authenticatedClient = CreateAuthenticatedClient( + userId: "00000000-0000-0000-0000-0000000000A1", + userName: "integration-test-user"); + using var content = new StringContent("{}", Encoding.UTF8, "application/json"); + + // Act. + using HttpResponseMessage response = await authenticatedClient.PostAsync( + "/api/v1/Requests", content); + + // Assert. + response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized, + "a valid bearer token must pass the JWT bearer middleware"); + response.StatusCode.Should().NotBe(HttpStatusCode.Forbidden, + "a valid bearer token without role claims must not be forbidden by the default policy"); + } + } +} diff --git a/Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtLoginIssuesTokenTests.cs b/Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtLoginIssuesTokenTests.cs new file mode 100644 index 00000000..9c5c3e95 --- /dev/null +++ b/Sources/Tests/ProjectV.CommunicationWebService.Tests/Scenarios/Jwt/JwtLoginIssuesTokenTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection; +using ProjectV.CommonWebApi.Authorization.Passwords; +using ProjectV.DataAccessLayer.Services.Users; +using ProjectV.Models.Authorization; +using ProjectV.Models.Users; +using ProjectV.Models.WebServices.Requests; +using ProjectV.Models.WebServices.Responses; +using Xunit; + +namespace ProjectV.CommunicationWebService.Tests.Scenarios.Jwt +{ + /// + /// Scenario JWT-3: POST /api/v1/Users/login with valid in-memory + /// credentials issues a JWT pair. + /// + /// + /// The scenario seeds a single user into the in-memory user store via + /// the production (so the stored password + /// salt and hash format match exactly what + /// UserService.LoginAsync expects), POSTs credentials at + /// /api/v1/Users/login, and asserts a 200 response with a non-null + /// AccessToken on the deserialised . + /// The ShouldCreateSystemUser flag is held OFF to avoid the + /// fire-and-forget seed race in UserService's constructor — the + /// scenario controls the entire user-store contents directly. + /// + [Trait("Category", "Integration")] + public sealed class JwtLoginIssuesTokenTests : JwtAuthScenarioBaseTest + { + private const string TestUserName = "test-user-jwt-3"; + private const string TestPassword = "Sup3rS3cret-Test-Pwd-JWT-3"; + + /// + /// Initializes a new instance of the + /// class. + /// + public JwtLoginIssuesTokenTests() + : base( + extraConfiguration: BuildExtraConfiguration(), + configureTestServices: null) + { + } + + /// + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await SeedTestUserAsync(Factory.Services); + } + + /// + /// Scenario JWT-3 — login with valid in-memory credentials returns + /// 200 + a token pair. + /// + [Fact] + public async Task Login_WithValidInMemoryCredentials_ReturnsTokenResponse() + { + // Arrange. + var request = new LoginRequest + { + UserName = TestUserName, + Password = TestPassword + }; + using var requestContent = JsonContent.Create(request); + + // Act. + using HttpResponseMessage response = await Client.PostAsync( + "/api/v1/Users/login", requestContent); + + // Assert. + response.StatusCode.Should().Be(HttpStatusCode.OK, + "valid credentials must be accepted by the login endpoint"); + + string payload = await response.Content.ReadAsStringAsync(); + payload.Should().NotBeNullOrWhiteSpace( + "the login endpoint must return a non-empty body on success"); + + using var doc = JsonDocument.Parse(payload); + JsonElement root = doc.RootElement; + + // The CommunicationWebService MVC pipeline is wired with + // AddNewtonsoftJson() which defaults to camelCase property + // names — so look up both casings to stay robust against + // future JSON-policy changes. + JsonElement? accessTokenElement = FindPropertyAnyCase(root, "AccessToken"); + accessTokenElement.Should().NotBeNull( + "TokenResponse JSON must expose an access-token property"); + + accessTokenElement!.Value.ValueKind.Should().Be(JsonValueKind.Object, + "AccessToken is an AccessTokenData record serialised as a JSON object"); + + JsonElement? tokenElement = FindPropertyAnyCase(accessTokenElement.Value, "Token"); + tokenElement.Should().NotBeNull( + "AccessToken must carry the serialised JWT string"); + + tokenElement!.Value.ValueKind.Should().Be(JsonValueKind.String); + tokenElement.Value.GetString().Should().NotBeNullOrWhiteSpace( + "AccessToken.Token must be a non-empty signed JWT"); + } + + private static IReadOnlyDictionary BuildExtraConfiguration() + { + // Keep ShouldCreateSystemUser off so the UserService.ctor does not + // race with the test seed; the test owns the entire in-memory + // store. + return new Dictionary + { + ["UserServiceOptions:AllowSignup"] = "false", + ["UserServiceOptions:ShouldCreateSystemUser"] = "false", + ["UserServiceOptions:CanUseSystemUserToAuthenticate"] = "false", + }; + } + + private static async Task SeedTestUserAsync(IServiceProvider services) + { + var passwordManager = services.GetRequiredService(); + var userInfoService = services.GetRequiredService(); + + byte[] salt = passwordManager.GetSecureSalt(); + Password hashedPassword = passwordManager.HashUsingPbkdf2( + Password.Wrap(TestPassword), salt); + + var user = new UserInfo( + id: UserId.Create(), + userName: UserName.Wrap(TestUserName), + password: hashedPassword, + passwordSalt: Convert.ToBase64String(salt), + creationTimeUtc: DateTime.UtcNow, + active: true, + refreshToken: null + ); + + await userInfoService.AddAsync(user); + } + + private static JsonElement? FindPropertyAnyCase(JsonElement element, string propertyName) + { + foreach (JsonProperty property in element.EnumerateObject()) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + return property.Value; + } + } + + return null; + } + } +} diff --git a/Sources/Tests/ProjectV.Core.Tests/Net/CommunicationServiceClientTests.cs b/Sources/Tests/ProjectV.Core.Tests/Net/CommunicationServiceClientTests.cs new file mode 100644 index 00000000..d4c00e74 --- /dev/null +++ b/Sources/Tests/ProjectV.Core.Tests/Net/CommunicationServiceClientTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using AwesomeAssertions; +using Newtonsoft.Json; +using ProjectV.Configuration.Options; +using ProjectV.Core.Services.Clients; +using ProjectV.Models.Authorization.Tokens; +using ProjectV.Models.WebServices.Requests; +using ProjectV.Models.WebServices.Responses; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Http; +using ProjectV.Tests.Shared.Helpers.Mocks.Net; +using Xunit; + +namespace ProjectV.Core.Tests.Net +{ + /// + /// Unit tests for . + /// + /// + /// + /// Substitutes via NSubstitute and + /// returns a real backed by an in-test + /// (DelegatingHandler subclass) — the + /// anti-pattern of substituting with + /// NSubstitute is avoided because NSubstitute cannot mock the protected + /// SendAsync method. + /// + /// + /// The original intent was a "throws AuthFailureException on 401" test; + /// the production code instead returns + /// Result.Error<ErrorResponse> on non-success status codes + /// via + /// — it does NOT throw. The test was adjusted to match observed behaviour. + /// + /// + [Trait("Category", "Unit")] + public sealed class CommunicationServiceClientTests : BaseMockTest + { + private const string TestBaseAddress = "http://localhost:8000/"; + private const string TestLoginApiUrl = "/api/v1/Users/Login"; + private const string TestRequestApiUrl = "/api/v1/Requests"; + + public CommunicationServiceClientTests() + { + } + + [Fact] + public async Task LoginAsync_WithSuccessfulResponse_ReturnsTokenResponse() + { + // Arrange. + var token = new TokenResponse + { + AccessToken = new AccessTokenData( + Token: "access-token-jwt", + ExpiryDateUtc: DateTime.UtcNow.AddMinutes(15) + ), + RefreshToken = new RefreshTokenData( + Token: "refresh-token-jwt", + ExpiryDateUtc: DateTime.UtcNow.AddDays(7) + ) + }; + string body = JsonConvert.SerializeObject(token); + + using var handler = new FakeHttpMessageHandler(_ => CreateJsonResponse(HttpStatusCode.OK, body)); + using var sut = CreateSut(handler); + + // Act. + var actualValue = await sut.LoginAsync( + new LoginRequest { UserName = "user", Password = "pass" } + ); + + // Assert. + actualValue.IsSuccess.Should().BeTrue(); + actualValue.Ok.Should().NotBeNull(); + actualValue.Ok!.AccessToken.Should().NotBeNull(); + actualValue.Ok!.AccessToken.Token.Should().Be("access-token-jwt"); + handler.CallCount.Should().Be(1); + } + + [Fact] + public async Task LoginAsync_With401Unauthorized_ReturnsErrorResponseWithCode401() + { + // Arrange. + var errorPayload = new ErrorResponse + { + Success = false, + ErrorCode = "401", + ErrorMessage = "Invalid credentials." + }; + string body = JsonConvert.SerializeObject(errorPayload); + + using var handler = new FakeHttpMessageHandler( + _ => CreateJsonResponse(HttpStatusCode.Unauthorized, body)); + using var sut = CreateSut(handler); + + // Act. + var actualValue = await sut.LoginAsync( + new LoginRequest { UserName = "user", Password = "wrong" } + ); + + // Assert. + actualValue.IsSuccess.Should().BeFalse(); + actualValue.Error.Should().NotBeNull(); + actualValue.Error!.ErrorCode.Should().Be("401"); + handler.CallCount.Should().Be(1); + } + + [Fact] + public async Task LoginAsync_WithNullLoginRequest_ThrowsArgumentNullException() + { + // Arrange. + using var handler = new FakeHttpMessageHandler(_ => CreateJsonResponse(HttpStatusCode.OK, "{}")); + using var sut = CreateSut(handler); + + // Act. / Assert. + var act = async () => await sut.LoginAsync(login: null!); + await act.Should() + .ThrowAsync() + .WithParameterName("login"); + } + + /// + /// Constructs a real with a + /// substituted that returns an + /// backed by the supplied + /// . + /// + private CommunicationServiceClient CreateSut(FakeHttpMessageHandler handler) + { + // CreateClientWithOptions appends Configure* calls to a fresh HttpClient + // returned by CreateClient — the handler must be passed at HttpClient + // construction time (not via the factory). + var client = new HttpClient(handler, disposeHandler: false); + var httpClientFactory = new TestHttpClientFactoryBuilder(Fixture) + .WithHttpClient(client) + .Build(); + + var serviceOptions = new ProjectVServiceOptions + { + RestApi = new RestApiOptions + { + CommunicationServiceBaseAddress = TestBaseAddress, + CommunicationServiceLoginApiUrl = TestLoginApiUrl, + CommunicationServiceRequestApiUrl = TestRequestApiUrl, + ConfigurationServiceBaseAddress = TestBaseAddress, + ConfigurationServiceApiUrl = "/api/v1/Configuration", + ProcessingServiceBaseAddress = TestBaseAddress, + ProcessingServiceApiUrl = "/api/v1/Processing" + }, + HttpClient = new HttpClientOptions + { + HttpClientDefaultName = "test-client", + ShouldDisposeHttpClient = false, + RetryCountOnFailed = 0, + RetryCountOnAuth = 0 + } + }; + var userServiceOptions = new UserServiceOptions + { + CanUseSystemUserToAuthenticate = false + }; + + return new CommunicationServiceClient( + httpClientFactory, serviceOptions, userServiceOptions + ); + } + + private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, string body) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + } + + } +} diff --git a/Sources/Tests/ProjectV.Core.Tests/Net/HttpClientPollyPolicyTests.cs b/Sources/Tests/ProjectV.Core.Tests/Net/HttpClientPollyPolicyTests.cs new file mode 100644 index 00000000..a57b7df9 --- /dev/null +++ b/Sources/Tests/ProjectV.Core.Tests/Net/HttpClientPollyPolicyTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection; +using ProjectV.Configuration.Options; +using ProjectV.Core.DependencyInjection; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Http; +using Xunit; + +namespace ProjectV.Core.Tests.Net +{ + /// + /// Unit tests for the Polly retry policy wired by + /// AddHttpClientWithOptions / + /// HttpClientBuilderExtensions.AddHttpErrorPoliciesWithOptions. + /// + /// + /// + /// Uses an in-test (DelegatingHandler + /// subclass) to simulate transient HTTP errors — the + /// Substitute.For<HttpMessageHandler> anti-pattern is avoided + /// because NSubstitute cannot mock protected methods. Production code + /// under test: + /// services.AddHttpClient(name).AddHttpOptions(options) → + /// AddTransientHttpErrorPolicy(...) → + /// WaitAndRetryWithOptionsAsync(retryCount = RetryCountOnFailed, + /// retryTimeout = RetryTimeoutOnFailed). AddTransientHttpErrorPolicy + /// covers HTTP 5xx + 408 + network failures by default. + /// + /// + [Trait("Category", "Unit")] + public sealed class HttpClientPollyPolicyTests : BaseMockTest + { + private const string TestClientName = "test-polly-client"; + + public HttpClientPollyPolicyTests() + { + } + + [Fact] + public async Task AddHttpClientWithOptions_With503TransientThenOk_RetriesUntilSuccess() + { + // Arrange. + // The Polly retry policy is configured with RetryCountOnFailed = 3 and a + // 1 ms back-off (overridden here so the test finishes in well under a + // second). Queue: [503, 503, 503, 200] → expect 4 handler invocations. + var responses = new Queue(new[] + { + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.OK, + }); + var handler = new FakeHttpMessageHandler(_ => + { + HttpStatusCode statusCode = responses.Count > 0 + ? responses.Dequeue() + : HttpStatusCode.OK; + return new HttpResponseMessage(statusCode); + }); + + HttpClient client = BuildHttpClientWithRetryPolicy( + retryCountOnFailed: 3, + retryTimeoutOnFailed: TimeSpan.FromMilliseconds(1), + primaryHandler: handler + ); + + // Act. + using HttpResponseMessage response = await client.GetAsync("/probe"); + + // Assert. + response.StatusCode.Should().Be(HttpStatusCode.OK); + handler.CallCount.Should().Be(4, + "the policy retries 3 times after the initial 503 then succeeds"); + } + + [Fact] + public async Task AddHttpClientWithOptions_WithAlways503_StopsAfterRetryCount() + { + // Arrange. + // Every response is 503; the policy retries RetryCountOnFailed = 2 + // times after the initial attempt, then surfaces the final 503 to the + // caller. Expect 1 + 2 = 3 handler invocations. + var handler = new FakeHttpMessageHandler(_ => + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); + + HttpClient client = BuildHttpClientWithRetryPolicy( + retryCountOnFailed: 2, + retryTimeoutOnFailed: TimeSpan.FromMilliseconds(1), + primaryHandler: handler + ); + + // Act. + using HttpResponseMessage response = await client.GetAsync("/probe"); + + // Assert. + response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + handler.CallCount.Should().Be(3, + "initial attempt + 2 retries before giving up"); + } + + [Fact] + public async Task AddHttpClientWithOptions_With200OnFirstCall_DoesNotRetry() + { + // Arrange. + var handler = new FakeHttpMessageHandler(_ => + new HttpResponseMessage(HttpStatusCode.OK)); + + HttpClient client = BuildHttpClientWithRetryPolicy( + retryCountOnFailed: 3, + retryTimeoutOnFailed: TimeSpan.FromMilliseconds(1), + primaryHandler: handler + ); + + // Act. + using HttpResponseMessage response = await client.GetAsync("/probe"); + + // Assert. + response.StatusCode.Should().Be(HttpStatusCode.OK); + handler.CallCount.Should().Be(1, + "no retry when the first response is a success status code"); + } + + /// + /// Builds a by registering one via + /// AddHttpClientWithOptions on a fresh + /// , then overriding the primary + /// HTTP message handler with the supplied test handler so the + /// Polly retry policy is the only production behavior under test. + /// + private static HttpClient BuildHttpClientWithRetryPolicy( + int retryCountOnFailed, + TimeSpan retryTimeoutOnFailed, + HttpMessageHandler primaryHandler) + { + var options = new HttpClientOptions + { + HttpClientDefaultName = TestClientName, + RetryCountOnFailed = retryCountOnFailed, + RetryTimeoutOnFailed = retryTimeoutOnFailed, + RetryCountOnAuth = 0, + RetryTimeoutOnAuth = TimeSpan.FromMilliseconds(1), + ShouldDisposeHttpClient = false + }; + + var services = new ServiceCollection(); + services.AddHttpClientWithOptions(options) + .ConfigurePrimaryHttpMessageHandler(() => primaryHandler); + + ServiceProvider provider = services.BuildServiceProvider(); + IHttpClientFactory factory = provider.GetRequiredService(); + HttpClient client = factory.CreateClient(TestClientName); + client.BaseAddress = new Uri("http://localhost:8000/"); + return client; + } + + } +} diff --git a/Sources/Tests/ProjectV.Core.Tests/ProjectV.Core.Tests.csproj b/Sources/Tests/ProjectV.Core.Tests/ProjectV.Core.Tests.csproj new file mode 100644 index 00000000..2b416f77 --- /dev/null +++ b/Sources/Tests/ProjectV.Core.Tests/ProjectV.Core.Tests.csproj @@ -0,0 +1,34 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.Core.Tests + false + false + + + + + + + + + + + + + + + diff --git a/Sources/Tests/ProjectV.Core.Tests/ShellBuilders/ShellBuilderDirectorTests.cs b/Sources/Tests/ProjectV.Core.Tests/ShellBuilders/ShellBuilderDirectorTests.cs new file mode 100644 index 00000000..1c279a8a --- /dev/null +++ b/Sources/Tests/ProjectV.Core.Tests/ShellBuilders/ShellBuilderDirectorTests.cs @@ -0,0 +1,177 @@ +using System; +using AutoFixture; +using AwesomeAssertions; +using NSubstitute; +using ProjectV.Core.ShellBuilders; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Stubs.Core; +using Xunit; + +namespace ProjectV.Core.Tests.ShellBuilders +{ + /// + /// Unit tests for the orchestrator. + /// + /// + /// The director coordinates the GoF Builder pattern: it invokes + /// first, then the five + /// Build* steps in declared order, and finally + /// . These tests verify that + /// contract via an NSubstitute substitute of . + /// + [Trait("Category", "Unit")] + public sealed class ShellBuilderDirectorTests : BaseMockTest + { + public ShellBuilderDirectorTests() + { + } + + [Fact] + public void Constructor_WithNullShellBuilder_ThrowsArgumentNullException() + { + // Act. / Assert. + var act = () => new ShellBuilderDirector(shellBuilder: null!); + act.Should() + .Throw() + .WithParameterName("shellBuilder"); + } + + [Fact] + public void Constructor_WithValidShellBuilder_DoesNotThrow() + { + // Arrange. + var shellBuilder = CreateShellBuilder(); + + // Act. + var act = () => new ShellBuilderDirector(shellBuilder); + + // Assert. + act.Should().NotThrow(); + } + + [Fact] + public void ChangeShellBuilder_WithNull_ThrowsArgumentNullException() + { + // Arrange. + var shellBuilder = CreateShellBuilder(); + var director = BuildSut(shellBuilder); + + // Act. / Assert. + var act = () => director.ChangeShellBuilder(newBuilder: null!); + act.Should() + .Throw() + .WithParameterName("newBuilder"); + } + + [Fact] + public void MakeShell_InvokesEveryBuilderStep() + { + // Arrange. + var expectedShell = CreateRealEmptyShell(); + var shellBuilder = CreateShellBuilder(expectedShell); + var director = BuildSut(shellBuilder); + + // Act. + Shell actualValue = director.MakeShell(); + + // Assert. + actualValue.Should().BeSameAs(expectedShell); + shellBuilder.Received(1).Reset(); + shellBuilder.Received(1).BuildMessageHandler(); + shellBuilder.Received(1).BuildInputManager(); + shellBuilder.Received(1).BuildCrawlersManager(); + shellBuilder.Received(1).BuildAppraisersManager(); + shellBuilder.Received(1).BuildOutputManager(); + shellBuilder.Received(1).GetResult(); + + // Cleanup local Shell — Director returns ownership to caller. + expectedShell.Dispose(); + } + + [Fact] + public void MakeShell_InvokesBuilderStepsInDeclaredOrder() + { + // Arrange. + var expectedShell = CreateRealEmptyShell(); + var shellBuilder = CreateShellBuilder(expectedShell); + var director = BuildSut(shellBuilder); + + // Act. + director.MakeShell(); + + // Assert. + Received.InOrder(() => + { + shellBuilder.Reset(); + shellBuilder.BuildMessageHandler(); + shellBuilder.BuildInputManager(); + shellBuilder.BuildCrawlersManager(); + shellBuilder.BuildAppraisersManager(); + shellBuilder.BuildOutputManager(); + shellBuilder.GetResult(); + }); + + expectedShell.Dispose(); + } + + [Fact] + public void MakeShell_AfterChangeShellBuilder_DispatchesToReplacedBuilder() + { + // Arrange. + var originalBuilder = CreateShellBuilder(); + var expectedShell = CreateRealEmptyShell(); + var replacementBuilder = CreateShellBuilder(expectedShell); + + var director = BuildSut(originalBuilder); + + // Act. + director.ChangeShellBuilder(replacementBuilder); + originalBuilder.ClearReceivedCalls(); + director.MakeShell(); + + // Assert. + originalBuilder.DidNotReceive().Reset(); + replacementBuilder.Received(1).Reset(); + replacementBuilder.Received(1).GetResult(); + + expectedShell.Dispose(); + } + + /// + /// Creates an substitute via the shared + /// . When + /// is provided, is stubbed to + /// return it; otherwise the substitute is returned bare. + /// + private IShellBuilder CreateShellBuilder(Shell? expectedResult = null) + { + var builder = Fixture.Create(); + if (expectedResult is not null) + { + builder.GetResult().Returns(expectedResult); + } + + return builder; + } + + /// + /// Builds the SUT from the + /// supplied collaborator. Per-test + /// builder helper that mirrors the production constructor. + /// + private static ShellBuilderDirector BuildSut(IShellBuilder shellBuilder) + { + return new ShellBuilderDirector(shellBuilder); + } + + /// + /// Creates a real empty instance via the + /// shared TestShellBuilder for use as the return value of + /// the substituted method. + /// + private static Shell CreateRealEmptyShell() + { + return TestShellBuilder.CreateWithoutSetup(); + } + } +} diff --git a/Sources/Tests/ProjectV.Core.Tests/ShellBuilders/ShellBuilderFromXDocumentTests.cs b/Sources/Tests/ProjectV.Core.Tests/ShellBuilders/ShellBuilderFromXDocumentTests.cs new file mode 100644 index 00000000..897750eb --- /dev/null +++ b/Sources/Tests/ProjectV.Core.Tests/ShellBuilders/ShellBuilderFromXDocumentTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Xml.Linq; +using AwesomeAssertions; +using ProjectV.Core.ShellBuilders; +using ProjectV.Tests.Shared.ForTests; +using Xunit; + +namespace ProjectV.Core.Tests.ShellBuilders +{ + /// + /// Unit tests for the XML + /// configuration parser. Focuses on the constructor null/root-null + /// guards and the Reset + GetResult contracts that do not + /// require a fully-populated XML config (the builder's individual + /// Build*Manager steps are exercised indirectly via the + /// test suite and the production + /// integration path). + /// + [Trait("Category", "Unit")] + public sealed class ShellBuilderFromXDocumentTests : BaseMockTest + { + public ShellBuilderFromXDocumentTests() + { + } + + [Fact] + public void Constructor_WithNullConfiguration_ThrowsArgumentNullException() + { + // Act. / Assert. + var act = () => new ShellBuilderFromXDocument(configuration: null!); + act.Should() + .Throw() + .WithParameterName("configuration"); + } + + [Fact] + public void Constructor_WithMissingRoot_ThrowsArgumentNullException() + { + // Arrange. + var configuration = new XDocument(); + + // Act. / Assert. + var act = () => new ShellBuilderFromXDocument(configuration); + act.Should() + .Throw() + .WithParameterName("Root"); + } + + [Fact] + public void Constructor_WithMinimalValidConfiguration_DoesNotThrow() + { + // Arrange. + var configuration = new XDocument( + new XElement("Root", + new XElement("ShellConfig") + ) + ); + + // Act. + var act = () => new ShellBuilderFromXDocument(configuration); + + // Assert. + act.Should().NotThrow(); + } + + [Fact] + public void GetResult_BeforeAnyBuildStep_ThrowsInvalidOperationException() + { + // Arrange. + var configuration = new XDocument( + new XElement("Root", + new XElement("ShellConfig") + ) + ); + var builder = new ShellBuilderFromXDocument(configuration); + + // Act. / Assert. + // GetResult() validates that all four manager slots are populated; + // since no Build*Manager step has been called yet, the first slot + // check (InputManager) trips the guard. + var act = () => builder.GetResult(); + act.Should() + .Throw() + .WithMessage("*InputManager*not built*"); + } + + [Fact] + public void Reset_AfterCtor_DoesNotThrow() + { + // Arrange. + var configuration = new XDocument( + new XElement("Root", + new XElement("ShellConfig") + ) + ); + var builder = new ShellBuilderFromXDocument(configuration); + + // Act. + var act = () => builder.Reset(); + + // Assert. + act.Should().NotThrow(); + } + + [Fact] + public void BuildMessageHandler_WithMissingElement_ThrowsInvalidOperationException() + { + // Arrange. + var configuration = new XDocument( + new XElement("Root", + new XElement("ShellConfig") + ) + ); + var builder = new ShellBuilderFromXDocument(configuration); + + // Act. / Assert. + // MessageHandler element is absent — production code throws + // InvalidOperationException with the parameter name in the message. + var act = () => builder.BuildMessageHandler(); + act.Should() + .Throw() + .WithMessage("*MessageHandler*"); + } + } +} diff --git a/Sources/Tests/ProjectV.Core.Tests/ShellTests.cs b/Sources/Tests/ProjectV.Core.Tests/ShellTests.cs new file mode 100644 index 00000000..a4d955d1 --- /dev/null +++ b/Sources/Tests/ProjectV.Core.Tests/ShellTests.cs @@ -0,0 +1,192 @@ +using System; +using System.Xml.Linq; +using AwesomeAssertions; +using ProjectV.Appraisers; +using ProjectV.Core.ShellBuilders; +using ProjectV.Crawlers; +using ProjectV.IO.Input; +using ProjectV.IO.Output; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Stubs.Appraisers; +using ProjectV.Tests.Shared.Helpers.Stubs.Core; +using ProjectV.Tests.Shared.Helpers.Stubs.Managers; +using Xunit; + +namespace ProjectV.Core.Tests +{ + /// + /// Unit tests for the orchestration entry point. + /// + /// + /// + /// takes concrete-typed (sealed) managers + /// (, , + /// , ) — + /// a known architectural anti-pattern in this codebase. Tests work + /// AROUND that coupling via real manager instances populated with + /// NSubstitute children ( + the manager + /// builders); they do NOT refactor . + /// + /// + /// Coverage scope for this Unit suite is intentionally narrow: + /// constructor null-guards, property surface, + /// idempotency, and the + /// static factory. The Run success / error / output-error + /// branches are NOT exercised here because the Gridsum.DataflowEx + /// pipeline that Run drives requires a fully-composed pipeline + /// (at least one inputter, crawler, and appraiser per stage) to + /// terminate deterministically — that scenario belongs in an + /// a future end-to-end or JWT integration test plan. + /// + /// + [Trait("Category", "Unit")] + public sealed class ShellTests : BaseMockTest + { + public ShellTests() + { + } + + [Fact] + public void Constructor_WithValidManagers_PopulatesAllProperties() + { + // Arrange. + var inputManager = TestInputManagerBuilder.CreateWithoutSetup(); + var crawlersManager = TestCrawlersManagerBuilder.CreateWithoutSetup(); + var appraisersManager = TestAppraisersManagerBuilder.CreateWithoutSetup(); + var outputManager = TestOutputManagerBuilder.CreateWithoutSetup(); + + // Act. + using var shell = new Shell( + inputManager, crawlersManager, appraisersManager, outputManager, + boundedCapacity: 10 + ); + + // Assert. + shell.InputManager.Should().BeSameAs(inputManager); + shell.CrawlersManager.Should().BeSameAs(crawlersManager); + shell.AppraisersManager.Should().BeSameAs(appraisersManager); + shell.OutputManager.Should().BeSameAs(outputManager); + } + + [Fact] + public void Constructor_WithNullInputManager_ThrowsArgumentNullException() + { + // Arrange. + var crawlersManager = TestCrawlersManagerBuilder.CreateWithoutSetup(); + var appraisersManager = TestAppraisersManagerBuilder.CreateWithoutSetup(); + var outputManager = TestOutputManagerBuilder.CreateWithoutSetup(); + + // Act. / Assert. + var act = () => new Shell( + inputManager: null!, + crawlersManager, appraisersManager, outputManager, + boundedCapacity: 10 + ); + act.Should() + .Throw() + .WithParameterName("inputManager"); + } + + [Fact] + public void Constructor_WithNullCrawlersManager_ThrowsArgumentNullException() + { + // Arrange. + var inputManager = TestInputManagerBuilder.CreateWithoutSetup(); + var appraisersManager = TestAppraisersManagerBuilder.CreateWithoutSetup(); + var outputManager = TestOutputManagerBuilder.CreateWithoutSetup(); + + // Act. / Assert. + var act = () => new Shell( + inputManager, + crawlersManager: null!, + appraisersManager, outputManager, + boundedCapacity: 10 + ); + act.Should() + .Throw() + .WithParameterName("crawlersManager"); + } + + [Fact] + public void Constructor_WithNullAppraisersManager_ThrowsArgumentNullException() + { + // Arrange. + var inputManager = TestInputManagerBuilder.CreateWithoutSetup(); + var crawlersManager = TestCrawlersManagerBuilder.CreateWithoutSetup(); + var outputManager = TestOutputManagerBuilder.CreateWithoutSetup(); + + // Act. / Assert. + var act = () => new Shell( + inputManager, crawlersManager, + appraisersManager: null!, + outputManager, + boundedCapacity: 10 + ); + act.Should() + .Throw() + .WithParameterName("appraisersManager"); + } + + [Fact] + public void Constructor_WithNullOutputManager_ThrowsArgumentNullException() + { + // Arrange. + var inputManager = TestInputManagerBuilder.CreateWithoutSetup(); + var crawlersManager = TestCrawlersManagerBuilder.CreateWithoutSetup(); + var appraisersManager = TestAppraisersManagerBuilder.CreateWithoutSetup(); + + // Act. / Assert. + var act = () => new Shell( + inputManager, crawlersManager, appraisersManager, + outputManager: null!, + boundedCapacity: 10 + ); + act.Should() + .Throw() + .WithParameterName("outputManager"); + } + + [Fact] + public void Dispose_CalledTwice_IsIdempotent() + { + // Arrange. + var shell = TestShellBuilder.CreateWithoutSetup(); + + // Act. + shell.Dispose(); + var act = () => shell.Dispose(); + + // Assert. + act.Should().NotThrow(); + } + + [Fact] + public void CreateBuilderDirector_WithMinimalValidXDocument_ReturnsNonNullDirector() + { + // Arrange. + var configuration = CreateMinimalShellConfigXml(); + + // Act. + ShellBuilderDirector director = Shell.CreateBuilderDirector(configuration); + + // Assert. + director.Should().NotBeNull(); + } + + /// + /// Builds a minimal valid that satisfies the + /// constructor (only the + /// ShellConfig root element is required at construction time; + /// individual sub-elements are only parsed lazily during the + /// Build*Manager steps). + /// + private static XDocument CreateMinimalShellConfigXml() + { + return new XDocument( + new XElement("Root", + new XElement("ShellConfig") + ) + ); + } + } +} diff --git a/Sources/Tests/ProjectV.Crawlers.Tests/CrawlersManagerTests.cs b/Sources/Tests/ProjectV.Crawlers.Tests/CrawlersManagerTests.cs new file mode 100644 index 00000000..6437295b --- /dev/null +++ b/Sources/Tests/ProjectV.Crawlers.Tests/CrawlersManagerTests.cs @@ -0,0 +1,148 @@ +using System; +using System.Reflection; +using Acolyte.Common.Monads; +using AwesomeAssertions; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Mocks.Crawlers; +using Xunit; + +namespace ProjectV.Crawlers.Tests +{ + /// + /// Unit tests for , focused on + /// TryGetResponse's log+rethrow contract: when a child + /// throws synchronously from + /// , the manager must + /// re-throw the original exception unchanged (logging happens through a + /// static NLog logger that is out-of-scope to substitute here — + /// see remark below). + /// + /// + /// + /// CrawlersManager.TryGetResponse is private and is invoked + /// via the funcs constructed by . + /// To assert on the log+rethrow contract directly (without spinning up + /// the surrounding Gridsum.DataflowEx pipeline), the test invokes + /// TryGetResponse through reflection. This keeps the unit test + /// laser-focused on the manager's exception-handling logic; + /// integration-grade coverage of the same path through the real + /// dataflow is provided by + /// ProjectV.DataPipeline.Tests.DataflowPipelineTests. + /// + /// + /// is consumed via a + /// private static readonly field initialised through + /// LoggerFactory.CreateLoggerFor<CrawlersManager>(). That + /// static seam is not substitutable from a unit test without invasive + /// reflection on LoggerFactory internals; we therefore verify the + /// observable half of the contract (the exception propagates) and rely + /// on the hoisted + /// ProjectV.Tests.Shared.ForTests.TestModuleInitializer + + /// production code review to cover the _logger.Error(...) call. + /// The logger.Received(1).Error(...) assertion pattern is an + /// aspirational target that this unit suite intentionally does not chase + /// — the deviation is recorded in the PR that introduced this test class. + /// + /// + [Trait("Category", "Unit")] + public sealed class CrawlersManagerTests : BaseMockTest + { + public CrawlersManagerTests() + { + } + + [Fact] + public void TryGetResponse_OnException_RethrowsOriginalException() + { + // Arrange. + var expectedException = new InvalidOperationException( + "Simulated TMDb crawler failure for test." + ); + ICrawler throwingCrawler = CreateTmdbCrawler(throwOnGetResponse: expectedException); + + using var sut = new CrawlersManager(outputResults: false); + sut.Add(throwingCrawler); + + MethodInfo tryGetResponse = typeof(CrawlersManager).GetMethod( + "TryGetResponse", + BindingFlags.NonPublic | BindingFlags.Instance + )!; + tryGetResponse.Should().NotBeNull( + "CrawlersManager.TryGetResponse must remain a private instance method " + + "for this reflection-based unit test to find it"); + + // Act. + var act = () => tryGetResponse.Invoke( + sut, new object[] { throwingCrawler, "any-entity" } + ); + + // Assert. + // The reflection wrapper re-raises the original exception as the + // inner exception of TargetInvocationException — assert on the + // unwrapped form to keep the contract clear. + act.Should() + .Throw() + .WithInnerException() + .Which.Message.Should().Be(expectedException.Message); + } + + [Fact] + public void Constructor_DoesNotRequireAnyCrawlers() + { + // Arrange. / Act. + using var sut = new CrawlersManager(outputResults: false); + + // Assert. + sut.Should().NotBeNull( + "the manager must be constructable with zero crawlers — " + + "callers register child ICrawler implementations via Add(...) " + + "after construction" + ); + } + + [Fact] + public void Add_WithNullCrawler_ThrowsArgumentNullException() + { + // Arrange. + using var sut = new CrawlersManager(outputResults: false); + + // Act. + var act = () => sut.Add( + item: null! + ); + + // Assert. + act.Should() + .Throw() + .WithParameterName("item"); + } + + [Fact] + public void Remove_WithRegisteredCrawler_ReturnsTrueAndDropsTheCrawler() + { + // Arrange. + ICrawler crawler = CreateTmdbCrawler(); + using var sut = new CrawlersManager(outputResults: false); + sut.Add(crawler); + + // Act. + bool removed = sut.Remove(crawler); + + // Assert. + removed.Should().BeTrue( + "Remove must report success when the manager holds the supplied crawler" + ); + } + + #region Helper Methods + + private ICrawler CreateTmdbCrawler(Exception? throwOnGetResponse = null) + { + return new TestTmdbCrawlerBuilder(Fixture) + .ApplyIf(throwOnGetResponse is not null, x => x.WithThrowOnGetResponse(throwOnGetResponse!)) + .Build(); + } + + #endregion + } +} diff --git a/Sources/Tests/ProjectV.Crawlers.Tests/ProjectV.Crawlers.Tests.csproj b/Sources/Tests/ProjectV.Crawlers.Tests/ProjectV.Crawlers.Tests.csproj new file mode 100644 index 00000000..04f85369 --- /dev/null +++ b/Sources/Tests/ProjectV.Crawlers.Tests/ProjectV.Crawlers.Tests.csproj @@ -0,0 +1,26 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.Crawlers.Tests + false + false + + + + + + + + + + diff --git a/Sources/Tests/ProjectV.DataAccessLayer.Tests/ForTests/DbCollection.cs b/Sources/Tests/ProjectV.DataAccessLayer.Tests/ForTests/DbCollection.cs new file mode 100644 index 00000000..cbcc1e54 --- /dev/null +++ b/Sources/Tests/ProjectV.DataAccessLayer.Tests/ForTests/DbCollection.cs @@ -0,0 +1,21 @@ +using Xunit; + +namespace ProjectV.DataAccessLayer.Tests.ForTests +{ + /// + /// xUnit collection definition that ties every DAL integration test class + /// to a single shared — a single + /// Testcontainers PostgreSQL container is started once per assembly run, + /// and every test class decorated with + /// [Collection(DbCollection.Name)] joins it. + /// + [CollectionDefinition(Name)] + public sealed class DbCollection : ICollectionFixture + { + /// + /// Collection name used by on every + /// DAL integration test class. + /// + public const string Name = "ProjectV.DAL.Db"; + } +} diff --git a/Sources/Tests/ProjectV.DataAccessLayer.Tests/ForTests/DbCollectionFixture.cs b/Sources/Tests/ProjectV.DataAccessLayer.Tests/ForTests/DbCollectionFixture.cs new file mode 100644 index 00000000..458001d4 --- /dev/null +++ b/Sources/Tests/ProjectV.DataAccessLayer.Tests/ForTests/DbCollectionFixture.cs @@ -0,0 +1,166 @@ +using System.Threading.Tasks; +using DotNet.Testcontainers.Builders; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +namespace ProjectV.DataAccessLayer.Tests.ForTests +{ + /// + /// xUnit collection fixture that hosts a single + /// for every DAL integration test in + /// this assembly. The container starts at suite begin + /// () and stops at suite end + /// (); per-test data isolation is delegated to + /// TestDbHelper.TruncateAllTablesAsync in each test class's + /// . + /// + /// + /// + /// Schema bootstrap path. An initial EF Core migration generation was + /// attempted so this fixture could call + /// , but the attempt failed at + /// EF design-time model discovery (see Migrations/.gitkeep for the + /// blocking error). Both + /// and + /// walk the same broken + /// model, so this fixture takes the documented fallback: raw SQL + /// CREATE TABLE statements derived from the [Table] / + /// [Column] attributes on + /// JobDbInfo, UserDbInfo, and RefreshTokenDbInfo. + /// The fallback exercises the same Npgsql wire protocol and the same + /// service code paths; only the schema-emission machinery is bypassed. + /// + /// + /// CanUseDatabase = true is set explicitly on every constructed + /// — otherwise the service no-ops on + /// every call. + /// + /// + public sealed class DbCollectionFixture : IAsyncLifetime + { + private readonly PostgreSqlContainer _container; + + + /// + /// PostgreSQL connection string for the running test container. + /// Populated by ; null before the + /// fixture has started. + /// + public string ConnectionString { get; private set; } = default!; + + + /// + /// Initializes a new instance of the + /// class. Does NOT start the container — that happens in + /// per xUnit's + /// contract. + /// + public DbCollectionFixture() + { + // Pin the image via the new (required) builder ctor — + // avoids first-pull surprises on CI. The legacy parameterless + // builder + WithImage(...) chain is obsolete in Testcontainers 4.11. + _container = new PostgreSqlBuilder("postgres:16.4") + .WithDatabase("projectv_test") + .WithUsername("test_user") + .WithPassword("test_pass") + // Avoid the first-pull race where the port is bound before the + // server is ready to accept connections. + // UntilInternalTcpPortIsAvailable(5432) waits for the container + // process itself to bind 5432; equivalent to the legacy + // UntilPortIsAvailable strategy. + .WithWaitStrategy( + Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(5432) + ) + .Build(); + } + + + #region IAsyncLifetime Implementation + + /// + public async Task InitializeAsync() + { + await _container.StartAsync(); + ConnectionString = _container.GetConnectionString(); + await ApplySchemaAsync(); + } + + /// + public async Task DisposeAsync() + { + await _container.DisposeAsync(); + } + + #endregion + + /// + /// Builds a fresh pointing at this + /// fixture's running container. Tests call this in their + /// to get an isolated + /// DbContext for the system under test. + /// + public ProjectVDbContext CreateDbContext() + { + // CRITICAL: CanUseDatabase MUST be true — the production + // ProjectVDbContext.OnConfiguring / OnModelCreating short-circuit + // when it is false. + var options = new DatabaseOptions( + dbConnectionString: ConnectionString, + canUseDatabase: true + ); + return new ProjectVDbContext(options); + } + + private async Task ApplySchemaAsync() + { + // Raw SQL schema bootstrap — see on the class. Column + // shapes mirror the [Column("…")] attributes on + // ProjectV.DataAccessLayer.Services.{Jobs,Users,Tokens}.Models.*DbInfo; + // tables sit in the default "public" schema declared in + // ProjectVDbContext.OnModelCreating. + // + // Uses Npgsql directly rather than ProjectVDbContext.Database. + // ExecuteSqlRawAsync because ProjectVDbContext's OnModelCreating + // raises ModelValidator errors on the UserDbInfo.RefreshToken + // navigation — the SUT services route their SQL through the same + // context but only after we've materialised the schema. Bypassing + // EF here keeps the bootstrap independent of the broken model + // See DbCollectionFixture XML-doc remarks for the full rationale. + const string createSchemaSql = @" + CREATE TABLE IF NOT EXISTS ""public"".""jobs"" ( + ""id"" uuid NOT NULL PRIMARY KEY, + ""name"" text NOT NULL, + ""state"" integer NOT NULL, + ""result"" integer NOT NULL, + ""config"" text NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ""public"".""users"" ( + ""id"" uuid NOT NULL PRIMARY KEY, + ""user_name"" text NOT NULL, + ""password"" text NOT NULL, + ""password_salt"" text NOT NULL, + ""ts"" timestamp with time zone NOT NULL, + ""active"" boolean NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ""public"".""tokens"" ( + ""id"" uuid NOT NULL PRIMARY KEY, + ""user_name"" uuid NOT NULL, + ""token_hash"" text NOT NULL, + ""token_salt"" text NOT NULL, + ""ts"" timestamp with time zone NOT NULL, + ""expiry_date"" timestamp with time zone NOT NULL + ); + "; + + await using var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand(createSchemaSql, connection); + await command.ExecuteNonQueryAsync(); + } + } +} diff --git a/Sources/Tests/ProjectV.DataAccessLayer.Tests/ProjectV.DataAccessLayer.Tests.csproj b/Sources/Tests/ProjectV.DataAccessLayer.Tests/ProjectV.DataAccessLayer.Tests.csproj new file mode 100644 index 00000000..30cd6df2 --- /dev/null +++ b/Sources/Tests/ProjectV.DataAccessLayer.Tests/ProjectV.DataAccessLayer.Tests.csproj @@ -0,0 +1,33 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.DataAccessLayer.Tests + false + false + + + + + + + + + + + + diff --git a/Sources/Tests/ProjectV.DataAccessLayer.Tests/ProjectVDbContextSchemaTests.cs b/Sources/Tests/ProjectV.DataAccessLayer.Tests/ProjectVDbContextSchemaTests.cs new file mode 100644 index 00000000..a53bd8de --- /dev/null +++ b/Sources/Tests/ProjectV.DataAccessLayer.Tests/ProjectVDbContextSchemaTests.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Acolyte.Assertions; +using AwesomeAssertions; +using Microsoft.EntityFrameworkCore; +using ProjectV.DataAccessLayer.Tests.ForTests; +using ProjectV.Tests.Shared.ForTests; +using Xunit; + +namespace ProjectV.DataAccessLayer.Tests +{ + /// + /// Integration test asserting that the schema applied by + /// exposes the three expected DAL + /// tables in the public schema. The schema is bootstrapped via + /// raw SQL (see DbCollectionFixture.ApplySchemaAsync) rather than + /// EF Core migrations because the EF model-validator blocks context + /// initialisation — this test verifies the bootstrap is wired correctly. + /// + [Trait("Category", "Integration")] + [Trait("RequiresDocker", "true")] + [Collection(DbCollection.Name)] + public sealed class ProjectVDbContextSchemaTests : BaseMockTest, IAsyncLifetime + { + private readonly DbCollectionFixture _db; + + private ProjectVDbContext _context = default!; + + + /// + /// Initializes a new instance of the + /// class. + /// + public ProjectVDbContextSchemaTests(DbCollectionFixture db) + { + _db = db.ThrowIfNull(nameof(db)); + } + + + #region IAsyncLifetime Implementation + + /// + public Task InitializeAsync() + { + _context = _db.CreateDbContext(); + return Task.CompletedTask; + } + + /// + public async Task DisposeAsync() + { + await _context.DisposeAsync(); + } + + #endregion + + [Fact] + public async Task SchemaAfterBootstrapContainsAllExpectedTables() + { + // Arrange. + var expectedTables = new[] { "jobs", "users", "tokens" }; + + // Act. + HashSet actualTables = await QueryPublicSchemaTableNamesAsync(); + + // Assert. + actualTables.Should().Contain(expectedTables, + "the DbCollectionFixture must materialise the production " + + "DAL tables (jobs / users / tokens) in the `public` schema " + + "of the Testcontainers PostgreSQL instance."); + } + + [Fact] + public async Task CanUseDbIsTrueOnFixtureBackedContext() + { + // Arrange. / Act. + bool actualValue = _context.CanUseDb(); + + // Assert. + actualValue.Should().BeTrue( + "every DbContext produced by DbCollectionFixture must carry " + + "CanUseDatabase=true — otherwise OnConfiguring / OnModelCreating " + + "short-circuits and the context is unusable."); + + // Sanity check: round-trip a trivial query to confirm the Npgsql + // connection actually opens against the container. + await using DbConnection connection = _context.Database.GetDbConnection(); + await connection.OpenAsync(); + connection.State.Should().Be(System.Data.ConnectionState.Open); + } + + private async Task> QueryPublicSchemaTableNamesAsync() + { + const string sql = + @"SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE';"; + + await using DbConnection connection = _context.Database.GetDbConnection(); + await connection.OpenAsync(); + + await using DbCommand command = connection.CreateCommand(); + command.CommandText = sql; + await using DbDataReader reader = await command.ExecuteReaderAsync(); + + var result = new HashSet(); + while (await reader.ReadAsync()) + { + result.Add(reader.GetString(0)); + } + return result; + } + } +} diff --git a/Sources/Tests/ProjectV.DataAccessLayer.Tests/Services/Jobs/DatabaseJobInfoServiceTests.cs b/Sources/Tests/ProjectV.DataAccessLayer.Tests/Services/Jobs/DatabaseJobInfoServiceTests.cs new file mode 100644 index 00000000..f269d8ae --- /dev/null +++ b/Sources/Tests/ProjectV.DataAccessLayer.Tests/Services/Jobs/DatabaseJobInfoServiceTests.cs @@ -0,0 +1,128 @@ +using System.Threading.Tasks; +using Acolyte.Assertions; +using AwesomeAssertions; +using ProjectV.DataAccessLayer.Services.Jobs; +using ProjectV.DataAccessLayer.Tests.ForTests; +using ProjectV.Models.Internal.Jobs; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Generators.DataAccessLayer; +using Xunit; + +namespace ProjectV.DataAccessLayer.Tests.Services.Jobs +{ + /// + /// Integration tests for against a + /// real Testcontainers PostgreSQL instance via + /// — exercises Add/Find/Update on the + /// production Npgsql pipeline. + /// + [Trait("Category", "Integration")] + [Trait("RequiresDocker", "true")] + [Collection(DbCollection.Name)] + public sealed class DatabaseJobInfoServiceTests : BaseMockTest, IAsyncLifetime + { + private readonly DbCollectionFixture _db; + private readonly JobInfoGenerator _generator; + + private ProjectVDbContext _context = default!; + private TestDbHelper _dbHelper = default!; + private DatabaseJobInfoService _sut = default!; + + + /// + /// Initializes a new instance of the + /// class. The + /// is injected by xUnit's collection + /// fixture machinery (see ). + /// + public DatabaseJobInfoServiceTests(DbCollectionFixture db) + { + _db = db.ThrowIfNull(nameof(db)); + _generator = new JobInfoGenerator(); + } + + + #region IAsyncLifetime Implementation + + /// + public async Task InitializeAsync() + { + _dbHelper = new TestDbHelper(_db.ConnectionString); + await _dbHelper.TruncateAllTablesAsync(); + + _context = _db.CreateDbContext(); + _sut = new DatabaseJobInfoService(_context, new DataAccessLayerMapper()); + } + + /// + public async Task DisposeAsync() + { + await _context.DisposeAsync(); + } + + #endregion + + [Fact] + public async Task AddAsyncWithValidJobInfoReturnsSavedRow() + { + // Arrange. + JobInfo jobInfo = _generator.GenerateJobInfo(); + + // Act. + int actualValue = await _sut.AddAsync(jobInfo); + + // Assert. + actualValue.Should().BeGreaterThan(0, + "DatabaseJobInfoService.AddAsync should return the number of " + + "rows persisted (1 in the happy path)."); + } + + [Fact] + public async Task FindByIdAsyncAfterAddReturnsEquivalentJob() + { + // Arrange. + JobInfo expected = _generator.GenerateJobInfo(); + await _sut.AddAsync(expected); + + // Act. + JobInfo? actualValue = await _sut.FindByIdAsync(expected.Id); + + // Assert. + actualValue.Should().NotBeNull(); + actualValue!.Id.Should().Be(expected.Id); + actualValue.Name.Should().Be(expected.Name); + actualValue.State.Should().Be(expected.State); + actualValue.Result.Should().Be(expected.Result); + actualValue.Config.Should().Be(expected.Config); + } + + [Fact] + public async Task UpdateAsyncWithExistingJobPersistsChanges() + { + // Arrange. + JobInfo original = _generator.GenerateJobInfo(); + await _sut.AddAsync(original); + + var mutated = new JobInfo( + id: original.Id, + name: original.Name, + state: original.State + 1, + result: original.Result + 1, + config: original.Config + ); + + // Detach the tracked entity so Update does not fight an in-memory copy. + _context.ChangeTracker.Clear(); + + // Act. + int rowsAffected = await _sut.UpdateAsync(mutated); + JobInfo? reread = await _sut.FindByIdAsync(original.Id); + + // Assert. + rowsAffected.Should().BeGreaterThan(0); + reread.Should().NotBeNull(); + reread!.State.Should().Be(mutated.State); + reread.Result.Should().Be(mutated.Result); + } + } +} diff --git a/Sources/Tests/ProjectV.DataAccessLayer.Tests/Services/Tokens/DatabaseRefreshTokenInfoServiceTests.cs b/Sources/Tests/ProjectV.DataAccessLayer.Tests/Services/Tokens/DatabaseRefreshTokenInfoServiceTests.cs new file mode 100644 index 00000000..bf146a0f --- /dev/null +++ b/Sources/Tests/ProjectV.DataAccessLayer.Tests/Services/Tokens/DatabaseRefreshTokenInfoServiceTests.cs @@ -0,0 +1,218 @@ +using System; +using System.Threading.Tasks; +using Acolyte.Assertions; +using AwesomeAssertions; +using ProjectV.DataAccessLayer.Services.Tokens; +using ProjectV.DataAccessLayer.Tests.ForTests; +using ProjectV.Models.Authorization.Tokens; +using ProjectV.Models.Users; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Generators.DataAccessLayer; +using ProjectV.Tests.Shared.Helpers.Generators.Models; +using Xunit; + +namespace ProjectV.DataAccessLayer.Tests.Services.Tokens +{ + /// + /// Integration tests for + /// against a real Testcontainers PostgreSQL instance — exercises + /// Add / FindById / FindByUserId / expiry round-trip on the production + /// Npgsql pipeline. + /// + [Trait("Category", "Integration")] + [Trait("RequiresDocker", "true")] + [Collection(DbCollection.Name)] + public sealed class DatabaseRefreshTokenInfoServiceTests : BaseMockTest, IAsyncLifetime + { + private readonly DbCollectionFixture _db; + private readonly RefreshTokenInfoGenerator _generator; + + private ProjectVDbContext _context = default!; + private TestDbHelper _dbHelper = default!; + private DatabaseRefreshTokenInfoService _sut = default!; + + + /// + /// Initializes a new instance of the + /// class. + /// + public DatabaseRefreshTokenInfoServiceTests(DbCollectionFixture db) + { + _db = db.ThrowIfNull(nameof(db)); + _generator = new RefreshTokenInfoGenerator(new UserIdGenerator()); + } + + + #region IAsyncLifetime Implementation + + /// + public async Task InitializeAsync() + { + _dbHelper = new TestDbHelper(_db.ConnectionString); + await _dbHelper.TruncateAllTablesAsync(); + + _context = _db.CreateDbContext(); + _sut = new DatabaseRefreshTokenInfoService(_context, new DataAccessLayerMapper()); + } + + /// + public async Task DisposeAsync() + { + await _context.DisposeAsync(); + } + + #endregion + + [Fact] + public async Task AddAsyncWithValidTokenPersistsRow() + { + // Arrange. + RefreshTokenInfo tokenInfo = _generator.GenerateRefreshTokenInfo(); + + // Act. + int actualValue = await _sut.AddAsync(tokenInfo); + + // Assert. + actualValue.Should().BeGreaterThan(0, + "DatabaseRefreshTokenInfoService.AddAsync should return the " + + "count of rows persisted (1 in the happy path)."); + } + + [Fact] + public async Task FindByIdAsyncAfterAddReturnsTokenWithExpectedExpiry() + { + // Arrange. + var creation = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime expiry = creation.AddDays(7); + RefreshTokenInfo expected = _generator.GenerateRefreshTokenInfo( + creationTimeUtc: creation, + expiryDateUtc: expiry + ); + await _sut.AddAsync(expected); + + // Act. + RefreshTokenInfo? actualValue = await _sut.FindByIdAsync(expected.Id); + + // Assert. + actualValue.Should().NotBeNull(); + actualValue!.Id.Should().Be(expected.Id); + actualValue.UserId.Should().Be(expected.UserId); + actualValue.TokenSalt.Should().Be(expected.TokenSalt); + // Postgres `timestamp with time zone` round-trips as Utc. + actualValue.ExpiryDateUtc.Should().BeCloseTo( + expiry, precision: TimeSpan.FromMilliseconds(1)); + } + + /// + /// Exercises the raw-Guid comparison fix: the service must look up + /// tokens by user id through the EF-translatable scalar column path + /// (not via the WrappedUserId computed property, which EF cannot lift + /// into SQL). Without this test the fix has zero integration coverage; + /// a regression that reintroduces + /// `token.WrappedUserId == userId` in the predicate would crash at + /// runtime instead of being caught here. Assertions extend beyond + /// id round-trip to cover the credential fields (TokenHash / + /// TokenSalt / ExpiryDate) — a regression that returned the wrong + /// token row for a user (e.g. predicate inversion, ordering bug) + /// would slip past an id-only assertion if any other token row + /// existed; the full-field round-trip rules that out. + /// + [Fact] + public async Task FindByUserIdAsyncAfterAddReturnsTokenWithExpectedFields() + { + // Arrange. + var creation = new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime expiry = creation.AddDays(14); + RefreshTokenInfo expected = _generator.GenerateRefreshTokenInfo( + creationTimeUtc: creation, + expiryDateUtc: expiry + ); + await _sut.AddAsync(expected); + + // Act. + RefreshTokenInfo? actualValue = await _sut.FindByUserIdAsync(expected.UserId); + + // Assert. + actualValue.Should().NotBeNull(); + actualValue!.Id.Should().Be(expected.Id); + actualValue.UserId.Should().Be(expected.UserId); + actualValue.TokenHash.Should().Be(expected.TokenHash); + actualValue.TokenSalt.Should().Be(expected.TokenSalt); + // Postgres `timestamp with time zone` round-trips as Utc. + actualValue.ExpiryDateUtc.Should().BeCloseTo( + expiry, precision: TimeSpan.FromMilliseconds(1)); + } + + /// + /// Covers the null-return branch of FindByUserIdAsync — the + /// service returns null when no token row matches the supplied + /// user id. A regression that altered the predicate (e.g. wrong field + /// comparison, inverted boolean, accidental cross-row match) would + /// surface here as a non-null value returned for a generated-but-not- + /// inserted user id. + /// + [Fact] + public async Task FindByUserIdAsyncForUnknownUserReturnsNull() + { + // Arrange — generate a user id but do NOT insert any token row + // for it. The user id space is `Guid.NewGuid()`-backed so the + // collision probability with any pre-existing test fixture data + // is effectively zero (the `TruncateAllTablesAsync` step in + // InitializeAsync also rules out leftover rows from earlier + // tests within this collection). + UserId unknownUserId = new UserIdGenerator().GenerateUserId(); + + // Act. + RefreshTokenInfo? actualValue = await _sut.FindByUserIdAsync(unknownUserId); + + // Assert. + actualValue.Should().BeNull( + "FindByUserIdAsync must return null when no token row " + + "exists for the supplied user id"); + } + + /// + /// Multi-row filter integration test for FindByUserIdAsync. + /// Inserts two tokens for two distinct users and asserts that + /// FindByUserIdAsync(userA.Id) returns tokenA (not tokenB). + /// + /// + /// The pre-existing happy-path test + /// () + /// operates on a single-row table — it would still pass even if the + /// EF-translated WHERE clause were a no-op (or the predicate were + /// inverted, or the comparison column were swapped) because there is + /// only one row that could be returned. This multi-row variant + /// exercises the actual filter: with two candidate rows in the + /// table, only the row whose user_name column matches the + /// supplied user id is allowed to surface. A regression that broke + /// the predicate would return the wrong token row here. + /// + [Fact] + public async Task FindByUserIdAsyncWithMultipleUsersReturnsOnlyMatchingRow() + { + // Arrange — insert two tokens for two distinct users. The + // RefreshTokenInfoGenerator emits a fresh Guid.NewGuid()-backed + // UserId on each call, so tokenA.UserId != tokenB.UserId with + // overwhelming probability. + RefreshTokenInfo tokenA = _generator.GenerateRefreshTokenInfo(); + RefreshTokenInfo tokenB = _generator.GenerateRefreshTokenInfo(); + tokenA.UserId.Should().NotBe(tokenB.UserId, + "the multi-row test requires two distinct user ids to be " + + "meaningful — generator-level guarantee, asserted defensively"); + await _sut.AddAsync(tokenA); + await _sut.AddAsync(tokenB); + + // Act. + RefreshTokenInfo? actualValue = await _sut.FindByUserIdAsync(tokenA.UserId); + + // Assert — must surface tokenA, must NOT surface tokenB. + actualValue.Should().NotBeNull(); + actualValue!.Id.Should().Be(tokenA.Id); + actualValue.UserId.Should().Be(tokenA.UserId); + actualValue.Id.Should().NotBe(tokenB.Id, + "the predicate must filter — returning tokenB here would " + + "indicate a broken WHERE clause"); + } + } +} diff --git a/Sources/Tests/ProjectV.DataAccessLayer.Tests/Services/Users/DatabaseUserInfoServiceTests.cs b/Sources/Tests/ProjectV.DataAccessLayer.Tests/Services/Users/DatabaseUserInfoServiceTests.cs new file mode 100644 index 00000000..f5279996 --- /dev/null +++ b/Sources/Tests/ProjectV.DataAccessLayer.Tests/Services/Users/DatabaseUserInfoServiceTests.cs @@ -0,0 +1,111 @@ +using System.Threading.Tasks; +using Acolyte.Assertions; +using AwesomeAssertions; +using ProjectV.DataAccessLayer.Services.Users; +using ProjectV.DataAccessLayer.Tests.ForTests; +using ProjectV.Models.Users; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Generators.DataAccessLayer; +using Xunit; + +namespace ProjectV.DataAccessLayer.Tests.Services.Users +{ + /// + /// Integration tests for against a + /// real Testcontainers PostgreSQL instance — exercises Add / + /// FindById / FindByUserName on the production Npgsql pipeline. + /// + [Trait("Category", "Integration")] + [Trait("RequiresDocker", "true")] + [Collection(DbCollection.Name)] + public sealed class DatabaseUserInfoServiceTests : BaseMockTest, IAsyncLifetime + { + private readonly DbCollectionFixture _db; + private readonly UserInfoGenerator _generator; + + private ProjectVDbContext _context = default!; + private TestDbHelper _dbHelper = default!; + private DatabaseUserInfoService _sut = default!; + + + /// + /// Initializes a new instance of the + /// class. + /// + public DatabaseUserInfoServiceTests(DbCollectionFixture db) + { + _db = db.ThrowIfNull(nameof(db)); + _generator = new UserInfoGenerator(); + } + + + #region IAsyncLifetime Implementation + + /// + public async Task InitializeAsync() + { + _dbHelper = new TestDbHelper(_db.ConnectionString); + await _dbHelper.TruncateAllTablesAsync(); + + _context = _db.CreateDbContext(); + _sut = new DatabaseUserInfoService(_context, new DataAccessLayerMapper()); + } + + /// + public async Task DisposeAsync() + { + await _context.DisposeAsync(); + } + + #endregion + + [Fact] + public async Task AddAsyncWithValidUserReturnsSavedRow() + { + // Arrange. + UserInfo userInfo = _generator.GenerateUserInfo(); + + // Act. + int actualValue = await _sut.AddAsync(userInfo); + + // Assert. + actualValue.Should().BeGreaterThan(0, + "DatabaseUserInfoService.AddAsync should return the count of " + + "rows persisted (1 in the happy path)."); + } + + [Fact] + public async Task FindByIdAsyncAfterAddReturnsEquivalentUser() + { + // Arrange. + UserInfo expected = _generator.GenerateUserInfo(); + await _sut.AddAsync(expected); + + // Act. + UserInfo? actualValue = await _sut.FindByIdAsync(expected.Id); + + // Assert. + actualValue.Should().NotBeNull(); + actualValue!.Id.Should().Be(expected.Id); + actualValue.UserName.Should().Be(expected.UserName); + actualValue.PasswordSalt.Should().Be(expected.PasswordSalt); + actualValue.Active.Should().Be(expected.Active); + } + + [Fact] + public async Task FindByUserNameAsyncAfterAddReturnsUser() + { + // Arrange. + UserInfo expected = _generator.GenerateUserInfo(); + await _sut.AddAsync(expected); + + // Act. + UserInfo? actualValue = await _sut.FindByUserNameAsync(expected.UserName); + + // Assert. + actualValue.Should().NotBeNull(); + actualValue!.Id.Should().Be(expected.Id); + actualValue.UserName.Should().Be(expected.UserName); + } + } +} diff --git a/Sources/Tests/ProjectV.DataPipeline.Tests/DataflowPipelineTests.cs b/Sources/Tests/ProjectV.DataPipeline.Tests/DataflowPipelineTests.cs new file mode 100644 index 00000000..630f2ecb --- /dev/null +++ b/Sources/Tests/ProjectV.DataPipeline.Tests/DataflowPipelineTests.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using NSubstitute; +using ProjectV.Appraisers; +using ProjectV.Crawlers; +using ProjectV.Models.Data; +using ProjectV.Models.Internal; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Mocks.Appraisers; +using ProjectV.Tests.Shared.Helpers.Mocks.Crawlers; +using Xunit; + +namespace ProjectV.DataPipeline.Tests +{ + /// + /// Integration tests for , focused on the + /// end-to-end path: a + /// real Gridsum.DataflowEx host wired with + /// → + /// → + /// → + /// , with NSubstitute + /// + leaves at the + /// stage boundaries. + /// + /// + /// + /// Tagged Integration (not Unit) because the test + /// exercises real TPL Dataflow blocks and the real + /// Gridsum.DataflowEx dataflow host. Mocks are confined to the + /// leaf + NSubstitute + /// substitutes — every block, every link, every completion-propagation + /// rule between stages is real. + /// + /// + /// The pipeline composition mirrors the production + /// Shell.ConstructPipeline ordering + /// (inputtersFlow.LinkTo(crawlersFlow) → + /// crawlersFlow.LinkTo(appraisersFlow) → + /// appraisersFlow.LinkTo(outputtersFlow)). The test never + /// touches the production Shell / InputManager / + /// CrawlersManager / AppraisersManager / + /// OutputManager wiring — those are covered by their own + /// suites — so a regression in pipeline plumbing surfaces here + /// independent of manager-level concerns. + /// + /// + [Trait("Category", "Integration")] + public sealed class DataflowPipelineTests : BaseMockTest + { + public DataflowPipelineTests() + { + } + + [Fact] + public async Task Execute_WithStubCrawlersAndAppraisers_ProducesExpectedOutput() + { + // Arrange. + // 1. Input entity name that survives the InputtersFlow filter + // (Length > MinWordLength = 2 + dedup). + const string entityName = "Inception"; + + // 2. The single inputter echoes the storage-name input back as + // the entity name so the crawler stage gets a known string. + var inputters = new[] + { + new Func>(_ => new[] { entityName }), + }; + var inputtersFlow = new InputtersFlow(inputters); + + // 3. NSubstitute ICrawler that yields one BasicInfo for any input. + var expectedBasicInfo = new BasicInfo( + thingId: 42, + title: entityName, + voteCount: 10_000, + voteAverage: 8.7 + ); + ICrawler crawlerSubstitute = CreateCrawler(expectedBasicInfo); + + var crawlerFuncs = new[] + { + new Func>( + name => crawlerSubstitute.GetResponse(name, outputResults: false) + ), + }; + var crawlersFlow = new CrawlersFlow(crawlerFuncs); + + // 4. NSubstitute IAppraiser that returns a fixed rating for any + // BasicInfo. The AppraisersFlow Funcotype uses DataType = + // typeof(BasicInfo) so all BasicInfo inputs match the + // IsAssignableFrom(...) filter. + var expectedRating = new RatingDataContainer( + dataHandler: expectedBasicInfo, + ratingValue: 9.1, + ratingId: Guid.NewGuid() + ); + var appraiserSubstitute = CreateAppraiser(expectedRating); + + var appraisersFuncs = new[] + { + new Funcotype( + func: info => appraiserSubstitute.GetRatings(info, outputResults: false), + dataType: typeof(BasicInfo) + ), + }; + var appraisersFlow = new AppraisersFlow(appraisersFuncs); + + // 5. OutputtersFlow with a capture-sink action that appends every + // received rating to a concurrent collection — assertions + // happen on that collection after the pipeline completes. + var collected = new ConcurrentBag(); + var outputterFuncs = new[] + { + new Action(collected.Add), + }; + var outputtersFlow = new OutputtersFlow(outputterFuncs); + + // 6. Wire the four stages in the same order as + // Shell.ConstructPipeline (Sources/Libraries/ProjectV.Core/Shell.cs). + inputtersFlow.LinkTo(crawlersFlow); + crawlersFlow.LinkTo(appraisersFlow); + appraisersFlow.LinkTo(outputtersFlow); + + var sut = new DataflowPipeline(inputtersFlow, outputtersFlow); + + // Act. + // The production `DataflowPipeline.Execute(string)` uses + // `InputtersFlow.ProcessAsync(new[] { input })` (the single-arg + // overload that leaves the flow open for more input). With a + // finite test input that single-arg overload never signals + // upstream completion, so `OutputtersFlow.CompletionTask` would + // block forever — the same Gridsum.DataflowEx empty/terminal + // pipeline deadlock that prevents Shell.Run from being covered + // by a unit test against a finite input. + // + // To exercise the SAME end-to-end wiring without hanging, this + // test reproduces Execute's logical contract via the two-arg + // ProcessAsync overload (`completeFlowOnFinish: true`) and then + // awaits OutputtersFlow.CompletionTask. The two-line body is + // the only "test-flavoured" deviation from the production + // Execute method — the pipeline plumbing under test is + // identical. + _ = sut; // suppress "unused" notice; we deliberately + // construct DataflowPipeline to verify ctor + property + // wiring stays correct even though we drive the flow + // through its public InputtersFlow seam. + await sut.InputtersFlow.ProcessAsync( + new[] { "any-storage-name" }, + completeFlowOnFinish: true + ); + await sut.OutputtersFlow.CompletionTask; + + // Assert. + // The pipeline emitted exactly one RatingDataContainer; the + // outputter sink captured it, and it carries the SAME values + // the substitute appraiser was configured to return. + collected.Should().HaveCount( + 1, + "the pipeline must deliver exactly one rating when given one " + + "entity name through one crawler / one appraiser / one outputter"); + + RatingDataContainer rating = collected.Single(); + rating.DataHandler.Should().BeSameAs( + expectedBasicInfo, + "the BasicInfo emitted by the crawler must flow unchanged through " + + "the AppraisersFlow into the OutputtersFlow"); + rating.RatingValue.Should().Be( + expectedRating.RatingValue, + "the rating value returned by the IAppraiser substitute must " + + "round-trip through the dataflow without mutation"); + rating.RatingId.Should().Be( + expectedRating.RatingId, + "the rating identifier must round-trip through the dataflow " + + "without mutation"); + + // The substitute crawler must have been invoked exactly once. + crawlerSubstitute.Received(1).GetResponse( + Arg.Any(), Arg.Any() + ); + // The substitute appraiser must have been invoked exactly once + // (one BasicInfo flowed through the crawler stage → one rating). + appraiserSubstitute.Received(1).GetRatings( + Arg.Any(), Arg.Any() + ); + } + + [Fact] + public void Constructor_WithNullInputtersFlow_ThrowsArgumentNullException() + { + // Arrange. + var outputtersFlow = new OutputtersFlow( + Array.Empty>() + ); + + // Act. + var act = () => new DataflowPipeline( + inputtersFlow: null!, + outputtersFlow: outputtersFlow + ); + + // Assert. + act.Should() + .Throw() + .WithParameterName("inputtersFlow"); + } + + [Fact] + public void Constructor_WithNullOutputtersFlow_ThrowsArgumentNullException() + { + // Arrange. + var inputtersFlow = new InputtersFlow( + Array.Empty>>() + ); + + // Act. + var act = () => new DataflowPipeline( + inputtersFlow: inputtersFlow, + outputtersFlow: null! + ); + + // Assert. + act.Should() + .Throw() + .WithParameterName("outputtersFlow"); + } + + #region Helper Methods + + private ICrawler CreateCrawler(BasicInfo response) + { + return new TestTmdbCrawlerBuilder(Fixture) + .WithResponse(response) + .Build(); + } + + private IAppraiser CreateAppraiser(RatingDataContainer rating) + { + return new TestAppraiserBuilder(Fixture) + .WithRating(rating) + .Build(); + } + + #endregion + } +} diff --git a/Sources/Tests/ProjectV.DataPipeline.Tests/InputtersFlowTests.cs b/Sources/Tests/ProjectV.DataPipeline.Tests/InputtersFlowTests.cs new file mode 100644 index 00000000..a66cc159 --- /dev/null +++ b/Sources/Tests/ProjectV.DataPipeline.Tests/InputtersFlowTests.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; +using AwesomeAssertions; +using ProjectV.Tests.Shared.ForTests; +using Xunit; + +namespace ProjectV.DataPipeline.Tests +{ + /// + /// Integration tests for , focused on the + /// deduplication + minimum-length filter encoded in the private + /// FilterInputData(string) predicate — a length guard + /// (inputtersData.Length > MinWordLength, with + /// MinWordLength == 2) plus a + /// ConcurrentDictionary<string, byte>.TryAdd dedup + /// step. + /// + /// + /// + /// Tagged Integration (not Unit) because the test + /// exercises a real TPL Dataflow + Gridsum.DataflowEx block. The flow + /// is constructed in the same shape the production + /// InputManager.CreateFlow(...) produces. + /// + /// + /// Observation strategy — why direct end-to-end driving is avoided: + /// the predicated link inside InputtersFlow.InitFlow + /// (inputFlow.LinkTo(_resultTransformer, FilterInputData)) + /// uses Gridsum.DataflowEx's Dataflow.LinkTo(other, predicate) + /// overload without a corresponding + /// LinkLeftToNull() escape hatch. With the default Gridsum + /// behaviour, items that the predicate rejects (the + /// filtered-by-length items AND the duplicates) are NOT discarded — + /// they accumulate in the inputFlow's source-block buffer and block + /// its completion. Awaiting + /// InputtersFlow.CompletionTask / + /// ProcessAsync(..., completeFlowOnFinish: true) therefore + /// deadlocks the test as soon as any item is filtered, which is + /// exactly the case the test wants to exercise. The production code + /// is "tested around" this deadlock by interrogating the predicate + /// directly rather than driving the flow end-to-end (see the next + /// paragraph). + /// + /// + /// To exercise the dedup / filter contract without hitting that + /// deadlock, the tests inspect the predicate directly via + /// reflection: the predicate's behaviour is the observable contract + /// (it deduplicates via a member-level + /// ConcurrentDictionary; it filters by length), and pulling + /// it out for direct interrogation is the minimal-invariant probe + /// that confirms the production behaviour without depending on + /// Gridsum's deadlocking completion semantics. + /// + /// + [Trait("Category", "Integration")] + public sealed class InputtersFlowTests : BaseMockTest + { + public InputtersFlowTests() + { + } + + [Fact] + public void Inputters_DeduplicateRepeatedItems() + { + // Arrange. + // Build a real InputtersFlow with a single inputter. The + // inputter delegate's body is never invoked by this test — + // we only need a constructed flow whose private FilterInputData + // method is observable. + var inputters = new[] + { + new Func>(_ => Array.Empty()), + }; + var sut = new InputtersFlow(inputters); + + MethodInfo filterPredicate = typeof(InputtersFlow).GetMethod( + "FilterInputData", + BindingFlags.NonPublic | BindingFlags.Instance + )!; + filterPredicate.Should().NotBeNull( + "InputtersFlow.FilterInputData must remain a private instance " + + "method for this reflection probe to find it"); + + bool Filter(string value) + => (bool) filterPredicate.Invoke(sut, new object[] { value })!; + + // Act. + // First-seen unique items pass; duplicates fail. + bool firstAlpha = Filter("alpha"); + bool secondAlpha = Filter("alpha"); // duplicate + bool firstBeta = Filter("beta"); + bool thirdAlpha = Filter("alpha"); // another duplicate + bool firstGamma = Filter("gamma"); + bool secondBeta = Filter("beta"); // duplicate + + // Assert. + firstAlpha.Should().BeTrue( + "the first occurrence of 'alpha' must pass the dedup gate"); + firstBeta.Should().BeTrue( + "the first occurrence of 'beta' must pass the dedup gate"); + firstGamma.Should().BeTrue( + "the first occurrence of 'gamma' must pass the dedup gate"); + + secondAlpha.Should().BeFalse( + "the second occurrence of 'alpha' must be filtered out by the " + + "ConcurrentDictionary.TryAdd dedup gate"); + thirdAlpha.Should().BeFalse( + "the third occurrence of 'alpha' must be filtered out"); + secondBeta.Should().BeFalse( + "the second occurrence of 'beta' must be filtered out"); + } + + [Fact] + public void Inputters_FilterOutTooShortItems() + { + // Arrange. + // MinWordLength = 2 → only items with Length > 2 survive. + var inputters = new[] + { + new Func>(_ => Array.Empty()), + }; + var sut = new InputtersFlow(inputters); + + MethodInfo filterPredicate = typeof(InputtersFlow).GetMethod( + "FilterInputData", + BindingFlags.NonPublic | BindingFlags.Instance + )!; + + bool Filter(string value) + => (bool) filterPredicate.Invoke(sut, new object[] { value })!; + + // Act / Assert. + Filter("").Should().BeFalse( + "empty string has Length 0 ≤ MinWordLength (2) — must be filtered"); + Filter("a").Should().BeFalse( + "single-character string has Length 1 ≤ MinWordLength (2) — must be filtered"); + Filter("ab").Should().BeFalse( + "two-character string has Length 2 ≤ MinWordLength (2) — must be filtered"); + Filter("abc").Should().BeTrue( + "three-character string has Length 3 > MinWordLength (2) — must pass"); + Filter("defg").Should().BeTrue( + "four-character string has Length 4 > MinWordLength (2) — must pass"); + } + + [Fact] + public async Task ProcessAsync_WithSingleUniqueItem_EmitsItDownstream() + { + // Arrange. + // Smoke test that confirms the dataflow plumbing is wired and the + // happy-path (no items filtered or deduplicated) completes + // through Gridsum's `completeFlowOnFinish: true` overload. + // Tests above cover the filter + dedup branches via reflection; + // this test confirms the end-to-end plumbing for the + // no-rejection case. + var inputters = new[] + { + new Func>(_ => new[] { "abc" }), + }; + var sut = new InputtersFlow(inputters); + + var collected = new System.Collections.Concurrent.ConcurrentBag(); + var sink = new ActionBlock(collected.Add); + sut.OutputBlock.LinkTo( + sink, + new DataflowLinkOptions { PropagateCompletion = true } + ); + + // Act. + await sut.ProcessAsync( + new[] { "any-storage-name" }, + completeFlowOnFinish: true + ); + await sink.Completion; + + // Assert. + collected.Should().BeEquivalentTo( + new[] { "abc" }, + "items that pass both the length filter (Length > 2) and " + + "the dedup gate must flow through to the OutputBlock"); + } + + [Fact] + public void MinWordLength_DefaultIsTwo() + { + // Arrange. + var inputters = new[] + { + new Func>(_ => Array.Empty()), + }; + + // Act. + var sut = new InputtersFlow(inputters); + + // Assert. + sut.MinWordLength.Should().Be( + 2, + "InputtersFlow's documented MinWordLength contract is 2 " + + "(the length-filter predicate FilterInputData checks `> MinWordLength`)"); + } + } +} diff --git a/Sources/Tests/ProjectV.DataPipeline.Tests/ProjectV.DataPipeline.Tests.csproj b/Sources/Tests/ProjectV.DataPipeline.Tests/ProjectV.DataPipeline.Tests.csproj new file mode 100644 index 00000000..0018b494 --- /dev/null +++ b/Sources/Tests/ProjectV.DataPipeline.Tests/ProjectV.DataPipeline.Tests.csproj @@ -0,0 +1,30 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.DataPipeline.Tests + false + false + + + + + + + + + + + + + + diff --git a/Sources/Tests/ProjectV.Executors.Tests/ProjectV.Executors.Tests.csproj b/Sources/Tests/ProjectV.Executors.Tests/ProjectV.Executors.Tests.csproj new file mode 100644 index 00000000..bc17450b --- /dev/null +++ b/Sources/Tests/ProjectV.Executors.Tests/ProjectV.Executors.Tests.csproj @@ -0,0 +1,26 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.Executors.Tests + false + false + + + + + + + + + + diff --git a/Sources/Tests/ProjectV.Executors.Tests/SimpleExecutorTests.cs b/Sources/Tests/ProjectV.Executors.Tests/SimpleExecutorTests.cs new file mode 100644 index 00000000..6ea396d8 --- /dev/null +++ b/Sources/Tests/ProjectV.Executors.Tests/SimpleExecutorTests.cs @@ -0,0 +1,130 @@ +using System; +using AwesomeAssertions; +using ProjectV.Models.Internal.Jobs; +using ProjectV.Tests.Shared.ForTests; +using Xunit; + +namespace ProjectV.Executors.Tests +{ + /// + /// Unit tests for , focused on the current + /// parameterless contract: + /// the overload throws synchronously + /// because the in-code TODO ("Take config from DB. / Create Shell. + /// / Execute Shell with data.") has not been implemented yet. + /// + /// + /// + /// This row is documented as tested around in + /// Docs/Testing/Coverage/test-coverage.md: the test asserts the CURRENT + /// (anti-pattern) behaviour — the parameterless overload is an unfinished stub + /// that throws rather than executing real + /// job logic. The eventual fix that wires the executor to the persisted job + /// config is deferred to a future phase. When that fix lands, this test should + /// be replaced with one that exercises the real persisted execution path. + /// + /// + /// The throw is synchronous (the production method is not async; + /// it raises before returning any ), + /// but the method's signature is Task<IReadOnlyList<ServiceStatus>> + /// so we use ThrowAsync<T> for AwesomeAssertions — it + /// handles both sync throws inside a Task-returning method and async + /// exceptions transparently. + /// + /// + [Trait("Category", "Unit")] + public sealed class SimpleExecutorTests : BaseMockTest + { + public SimpleExecutorTests() + { + } + + [Fact] + public async System.Threading.Tasks.Task ExecuteAsync_Parameterless_ThrowsNotImplementedException() + { + // Arrange. + var jobInfo = JobInfo.Create( + name: "ProjectV.Executors.Tests.SimpleExecutorTests", + config: "" + ); + var sut = new SimpleExecutor( + jobInfo: jobInfo, + executionsNumber: 1, + delayTime: TimeSpan.Zero + ); + + // Act. + var act = () => sut.ExecuteAsync(); + + // Assert. + await act.Should() + .ThrowAsync( + "the parameterless overload is an anti-pattern stub whose current " + + "behaviour is a synchronous throw with the in-code TODO message" + ); + } + + [Fact] + public void Constructor_WithNullJobInfo_ThrowsArgumentNullException() + { + // Arrange. / Act. + var act = () => new SimpleExecutor( + jobInfo: null!, + executionsNumber: 1, + delayTime: TimeSpan.Zero + ); + + // Assert. + act.Should() + .Throw() + .WithParameterName("jobInfo"); + } + + [Fact] + public void Constructor_WithZeroExecutionsNumber_ThrowsArgumentOutOfRangeException() + { + // Arrange. + var jobInfo = JobInfo.Create( + name: "ProjectV.Executors.Tests.SimpleExecutorTests", + config: "" + ); + + // Act. + var act = () => new SimpleExecutor( + jobInfo: jobInfo, + executionsNumber: 0, + delayTime: TimeSpan.Zero + ); + + // Assert. + act.Should() + .Throw() + .WithParameterName("executionsNumber"); + } + + [Fact] + public void Constructor_HappyPath_ExposesIdAndExecutionPropertiesFromArguments() + { + // Arrange. + var jobInfo = JobInfo.Create( + name: "ProjectV.Executors.Tests.SimpleExecutorTests", + config: "" + ); + var delayTime = TimeSpan.FromMilliseconds(123); + const int executionsNumber = 2; + + // Act. + var sut = new SimpleExecutor( + jobInfo: jobInfo, + executionsNumber: executionsNumber, + delayTime: delayTime + ); + + // Assert. + sut.Id.Should().Be(jobInfo.Id); + sut.ExecutionsNumber.Should().Be(executionsNumber); + sut.DelayTime.Should().Be(delayTime); + sut.RestartPoint.Should().Be(RestartPointKind.None); + } + } +} diff --git a/Sources/Tests/ProjectV.InputProcessing.Tests/InputManagerTests.cs b/Sources/Tests/ProjectV.InputProcessing.Tests/InputManagerTests.cs new file mode 100644 index 00000000..92a0a020 --- /dev/null +++ b/Sources/Tests/ProjectV.InputProcessing.Tests/InputManagerTests.cs @@ -0,0 +1,167 @@ +using System; +using AutoFixture; +using AwesomeAssertions; +using ProjectV.DataPipeline; +using ProjectV.IO.Input; +using ProjectV.Tests.Shared.ForTests; +using Xunit; + +namespace ProjectV.InputProcessing.Tests +{ + /// + /// Unit tests for 's public contract: + /// returns a non-null + /// regardless of whether the manager has + /// any registered inputters, the storage-name argument is empty, or both. + /// Also covers the constructor null/whitespace guard and the + /// Add / Remove registration round-trip. + /// + /// + /// Collaborator instances are supplied through + /// NSubstitute; the manager itself is the real concrete type. The + /// static _logger field on + /// is initialised through + /// LoggerFactory.CreateLoggerFor<InputManager>() — the + /// hoisted ProjectV.Tests.Shared.ForTests.TestModuleInitializer + /// installs an empty NLog config on assembly load so the type + /// initialiser does not write log files during the test run. + /// + [Trait("Category", "Unit")] + public sealed class InputManagerTests : BaseMockTest + { + private const string DefaultStorageName = "default-storage.csv"; + + public InputManagerTests() + { + } + + [Fact] + public void CreateFlow_ReturnsNonNullFlow() + { + // Arrange. + var sut = BuildSut(); + IInputter inputter = CreateInputter(); + sut.Add(inputter); + + // Act. + InputtersFlow actual = sut.CreateFlow("storage.csv"); + + // Assert. + actual.Should().NotBeNull( + "InputManager.CreateFlow must return a non-null InputtersFlow " + + "so the downstream DataflowPipeline can wire the inputters stage" + ); + } + + [Fact] + public void CreateFlow_WithNoInputters_ReturnsNonNullFlow() + { + // Arrange. + var sut = BuildSut(); + + // Act. + InputtersFlow actual = sut.CreateFlow("storage.csv"); + + // Assert. + actual.Should().NotBeNull( + "the contract holds even with zero inputters — Shell wires the " + + "flow before any inputter is necessarily registered" + ); + } + + // NOTE: there is intentionally NO CreateFlow_WithEmptyStorageName test + // for InputManager. Unlike OutputManager.CreateFlow (which only logs on + // the empty-storage-name fallback path), InputManager.CreateFlow also + // calls ProjectV.Communication.GlobalMessageHandler.OutputMessage(...), + // a static helper backed by a process-wide IMessageHandler that is null + // until a host (Shell/ServiceHost/ConsoleApp) registers one. Asserting + // on the empty-storage-name path here would either (a) require the test + // to mutate global static state (leaking across the xUnit assembly's + // parallel test runs) or (b) capture an ArgumentNullException out of + // the messaging seam, neither of which reflects the plan's + // CreateFlow-non-null contract. The non-empty-storage path is the + // contract Shell exercises in production; we test that path only here. + // The empty-storage-name code path is exercised through the higher- + // level Shell.Run integration coverage (currently "tested around" + // because of the Gridsum.DataflowEx empty-pipeline deadlock). + + [Fact] + public void Constructor_WithNullDefaultStorageName_ThrowsArgumentNullException() + { + // Arrange. / Act. + var act = () => new InputManager( + defaultStorageName: null! + ); + + // Assert. + act.Should() + .Throw() + .WithParameterName("defaultStorageName"); + } + + [Fact] + public void Constructor_WithWhitespaceDefaultStorageName_ThrowsArgumentException() + { + // Arrange. / Act. + var act = () => new InputManager(defaultStorageName: " "); + + // Assert. + act.Should() + .Throw() + .WithParameterName("defaultStorageName"); + } + + [Fact] + public void Add_WithNullInputter_ThrowsArgumentNullException() + { + // Arrange. + var sut = BuildSut(); + + // Act. + var act = () => sut.Add( + item: null! + ); + + // Assert. + act.Should() + .Throw() + .WithParameterName("item"); + } + + [Fact] + public void Remove_WithRegisteredInputter_ReturnsTrue() + { + // Arrange. + var sut = BuildSut(); + IInputter inputter = CreateInputter(); + sut.Add(inputter); + + // Act. + bool removed = sut.Remove(inputter); + + // Assert. + removed.Should().BeTrue( + "Remove must report success when the manager holds the supplied inputter" + ); + } + + /// + /// Creates a bare substitute via the shared + /// . Centralises substitute creation + /// so test bodies do not call Fixture.Create directly. + /// + private IInputter CreateInputter() + { + return Fixture.Create(); + } + + /// + /// Builds a default-storage SUT. + /// Per-class helper to keep test bodies focused on Arrange/Act/Assert. + /// + private static InputManager BuildSut() + { + return new InputManager(DefaultStorageName); + } + } +} diff --git a/Sources/Tests/ProjectV.InputProcessing.Tests/ProjectV.InputProcessing.Tests.csproj b/Sources/Tests/ProjectV.InputProcessing.Tests/ProjectV.InputProcessing.Tests.csproj new file mode 100644 index 00000000..64a74064 --- /dev/null +++ b/Sources/Tests/ProjectV.InputProcessing.Tests/ProjectV.InputProcessing.Tests.csproj @@ -0,0 +1,26 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.InputProcessing.Tests + false + false + + + + + + + + + + diff --git a/Sources/Tests/ProjectV.Models.Tests/Data/BasicInfoInvariantsTests.cs b/Sources/Tests/ProjectV.Models.Tests/Data/BasicInfoInvariantsTests.cs new file mode 100644 index 00000000..771bf6d3 --- /dev/null +++ b/Sources/Tests/ProjectV.Models.Tests/Data/BasicInfoInvariantsTests.cs @@ -0,0 +1,186 @@ +using AwesomeAssertions; +using Newtonsoft.Json; +using ProjectV.Models.Data; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Generators.Models; +using Xunit; + +namespace ProjectV.Models.Tests.Data +{ + /// + /// Unit tests for invariants — primitive + /// property defaults, virtual default, + /// equality semantics with floating-point tolerance, and the + /// Newtonsoft.Json round-trip honoured by the + /// [JsonConstructor]-annotated primary constructor. + /// + /// + /// The production primary ctor does NOT carry + /// Acolyte ThrowIfNull guards (every other ctor in + /// ProjectV.Models does — see CONVENTIONS.md). Testing the + /// observed behaviour here, not the convention; if a future plan adds + /// guards, the corresponding tests in this file should flip from + /// "accepts null/empty" to "rejects null/empty". + /// + [Trait("Category", "Unit")] + public sealed class BasicInfoInvariantsTests : BaseMockTest + { + private readonly BasicInfoGenerator _generator; + + public BasicInfoInvariantsTests() + { + _generator = new BasicInfoGenerator(); + } + + [Fact] + public void PrimitiveDefaultsAcceptedByConstructor() + { + // Arrange. / Act. + var info = new BasicInfo( + thingId: 0, + title: string.Empty, + voteCount: 0, + voteAverage: 0.0); + + // Assert. + info.ThingId.Should().Be(0); + info.Title.Should().BeEmpty(); + info.VoteCount.Should().Be(0); + info.VoteAverage.Should().Be(0.0); + } + + [Fact] + public void KindDefaultsToTypeName() + { + // Arrange. / Act. + var info = _generator.GenerateBasicInfo(); + + // Assert. + info.Kind.Should().Be(nameof(BasicInfo)); + } + + [Fact] + public void ConstructorAssignsAllPropertiesFromArguments() + { + // Arrange. + var info = _generator.CreateBasicInfo( + thingId: 42, title: "Title", voteCount: 100, voteAverage: 9.5); + + // Assert. + info.ThingId.Should().Be(42); + info.Title.Should().Be("Title"); + info.VoteCount.Should().Be(100); + info.VoteAverage.Should().Be(9.5); + } + + [Fact] + public void EqualsReturnsTrueForMemberwiseIdenticalInstances() + { + // Arrange. + var left = _generator.CreateBasicInfo( + thingId: 1, title: "Same", voteCount: 5, voteAverage: 7.7); + var right = _generator.CreateBasicInfo( + thingId: 1, title: "Same", voteCount: 5, voteAverage: 7.7); + + // Act. / Assert. + left.Should().Be(right); + (left == right).Should().BeTrue(); + (left != right).Should().BeFalse(); + left.GetHashCode().Should().Be(right.GetHashCode()); + } + + [Fact] + public void EqualsAppliesToleranceOnVoteAverage() + { + // Arrange. BasicInfo.IsEqual uses Math.Abs(diff) < 1e-6. + var left = _generator.CreateBasicInfo( + thingId: 1, title: "Same", voteCount: 5, voteAverage: 7.7); + var right = _generator.CreateBasicInfo( + thingId: 1, title: "Same", voteCount: 5, + voteAverage: 7.7 + 1e-9); + + // Act. / Assert. + left.Should().Be(right); + } + + [Fact] + public void EqualsReturnsFalseWhenAnyFieldDiffers() + { + // Arrange. + var baseline = _generator.CreateBasicInfo( + thingId: 1, title: "T", voteCount: 5, voteAverage: 7.7); + + // Act. + var differentId = _generator.CreateBasicInfo( + thingId: 2, title: "T", voteCount: 5, voteAverage: 7.7); + var differentTitle = _generator.CreateBasicInfo( + thingId: 1, title: "U", voteCount: 5, voteAverage: 7.7); + var differentVoteCount = _generator.CreateBasicInfo( + thingId: 1, title: "T", voteCount: 6, voteAverage: 7.7); + var differentVoteAverage = _generator.CreateBasicInfo( + thingId: 1, title: "T", voteCount: 5, voteAverage: 7.8); + + // Assert. + baseline.Should().NotBe(differentId); + baseline.Should().NotBe(differentTitle); + baseline.Should().NotBe(differentVoteCount); + baseline.Should().NotBe(differentVoteAverage); + } + + [Fact] + public void EqualsHandlesNullAndSelfReference() + { + // Arrange. + var info = _generator.GenerateBasicInfo(); + + // Act. / Assert. + info.Equals(info).Should().BeTrue(); + info.Equals(other: null).Should().BeFalse(); + } + + [Fact] + public void NewtonsoftJsonRoundTripsCompact() + { + // Arrange. + var expected = _generator.CreateBasicInfo( + thingId: 7, title: "Round-Trip", voteCount: 42, voteAverage: 8.4); + + // Act. + string json = JsonConvert.SerializeObject(expected); + BasicInfo? actual = JsonConvert.DeserializeObject(json); + + // Assert. + actual.Should().NotBeNull(); + actual.Should().Be(expected); + } + + [Fact] + public void NewtonsoftJsonRoundTripsPrettyPrinted() + { + // Arrange. + var expected = _generator.CreateBasicInfo( + thingId: 7, title: "Round-Trip", voteCount: 42, voteAverage: 8.4); + + // Act. + string json = JsonConvert.SerializeObject(expected, Formatting.Indented); + BasicInfo? actual = JsonConvert.DeserializeObject(json); + + // Assert. + actual.Should().NotBeNull(); + actual.Should().Be(expected); + } + + [Fact] + public void NewtonsoftJsonPreservesKindDiscriminator() + { + // Arrange. + var info = _generator.GenerateBasicInfo(); + + // Act. + string json = JsonConvert.SerializeObject(info); + + // Assert. + json.Should().Contain($"\"Kind\":\"{nameof(BasicInfo)}\""); + } + } +} diff --git a/Sources/Tests/ProjectV.Models.Tests/Exceptions/CannotGetTmdbConfigExceptionTests.cs b/Sources/Tests/ProjectV.Models.Tests/Exceptions/CannotGetTmdbConfigExceptionTests.cs new file mode 100644 index 00000000..db81c295 --- /dev/null +++ b/Sources/Tests/ProjectV.Models.Tests/Exceptions/CannotGetTmdbConfigExceptionTests.cs @@ -0,0 +1,42 @@ +using System; +using ProjectV.Models.Exceptions; +using ProjectV.Tests.Shared.ForTests; +using Xunit; + +namespace ProjectV.Models.Tests.Exceptions +{ + /// + /// Verifies the 3-constructor convention on + /// by inheriting + /// . Pre-canned + /// [Fact] methods on the base class cover the default, + /// message-only, and message + inner-exception constructors. + /// + [Trait("Category", "Unit")] + public sealed class CannotGetTmdbConfigExceptionTests + : BaseExceptionTests + { + public CannotGetTmdbConfigExceptionTests() + { + } + + /// + protected override CannotGetTmdbConfigException Create() + { + return new CannotGetTmdbConfigException(); + } + + /// + protected override CannotGetTmdbConfigException Create(string message) + { + return new CannotGetTmdbConfigException(message); + } + + /// + protected override CannotGetTmdbConfigException Create( + string message, Exception innerException) + { + return new CannotGetTmdbConfigException(message, innerException); + } + } +} diff --git a/Sources/Tests/ProjectV.Models.Tests/Exceptions/CommonExceptionsTestSuite.cs b/Sources/Tests/ProjectV.Models.Tests/Exceptions/CommonExceptionsTestSuite.cs new file mode 100644 index 00000000..938a442f --- /dev/null +++ b/Sources/Tests/ProjectV.Models.Tests/Exceptions/CommonExceptionsTestSuite.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using AwesomeAssertions; +using ProjectV.Models.Exceptions; +using ProjectV.Tests.Shared.ForTests; +using Xunit; + +namespace ProjectV.Models.Tests.Exceptions +{ + /// + /// Reflection-driven enforcement of the 3-constructor convention across + /// every sealed subclass in the + /// ProjectV.Models.Exceptions namespace. New custom exception + /// types are picked up automatically when they land in + /// Sources/Libraries/ProjectV.Models/Exceptions/ — no manual + /// [InlineData] wiring is needed. + /// + /// + /// The convention every custom exception in ProjectV follows (see + /// CONVENTIONS.md): + /// + /// Public parameterless ctor. + /// Public ctor ( message). + /// Public ctor ( message, + /// innerException). + /// + /// Asserting via reflection complements the per-type + /// suites (which verify the + /// ctors actually behave correctly): this suite confirms NO custom + /// exception silently drops one of the three. + /// + [Trait("Category", "Unit")] + public sealed class CommonExceptionsTestSuite : BaseMockTest + { + public CommonExceptionsTestSuite() + { + } + + /// + /// Enumerates every sealed subtype declared + /// in the assembly. + /// Reflection over means new + /// exception types added to ProjectV.Models.Exceptions are + /// auto-discovered. + /// + public static IEnumerable SealedExceptionTypes() + { + var assembly = typeof(CannotGetTmdbConfigException).Assembly; + foreach (Type type in assembly.GetExportedTypes()) + { + if (type.IsSealed + && typeof(Exception).IsAssignableFrom(type) + && !type.IsAbstract) + { + yield return new object[] { type }; + } + } + } + + [Theory] + [MemberData(nameof(SealedExceptionTypes))] + public void ExceptionTypeHasDefaultConstructor(Type exceptionType) + { + // Arrange. + exceptionType.Should().NotBeNull(); + + // Act. + ConstructorInfo? ctor = exceptionType.GetConstructor( + Type.EmptyTypes); + + // Assert. + ctor.Should().NotBeNull( + $"{exceptionType.FullName} must declare a public parameterless constructor." + ); + } + + [Theory] + [MemberData(nameof(SealedExceptionTypes))] + public void ExceptionTypeHasMessageConstructor(Type exceptionType) + { + // Arrange. + exceptionType.Should().NotBeNull(); + + // Act. + ConstructorInfo? ctor = exceptionType.GetConstructor( + new[] { typeof(string) }); + + // Assert. + ctor.Should().NotBeNull( + $"{exceptionType.FullName} must declare a public (string message) constructor." + ); + } + + [Theory] + [MemberData(nameof(SealedExceptionTypes))] + public void ExceptionTypeHasMessageAndInnerExceptionConstructor(Type exceptionType) + { + // Arrange. + exceptionType.Should().NotBeNull(); + + // Act. + ConstructorInfo? ctor = exceptionType.GetConstructor( + new[] { typeof(string), typeof(Exception) }); + + // Assert. + ctor.Should().NotBeNull( + $"{exceptionType.FullName} must declare a public " + + $"(string message, Exception innerException) constructor." + ); + } + + [Fact] + public void DiscoversAtLeastOneSealedExceptionType() + { + // Arrange. / Act. + var types = SealedExceptionTypes().ToList(); + + // Assert. + types.Should().NotBeEmpty( + "ProjectV.Models must declare at least one sealed exception type " + + "(CannotGetTmdbConfigException at minimum)." + ); + } + } +} diff --git a/Sources/Tests/ProjectV.Models.Tests/ProjectV.Models.Tests.csproj b/Sources/Tests/ProjectV.Models.Tests/ProjectV.Models.Tests.csproj new file mode 100644 index 00000000..4487ad19 --- /dev/null +++ b/Sources/Tests/ProjectV.Models.Tests/ProjectV.Models.Tests.csproj @@ -0,0 +1,32 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.Models.Tests + false + false + + + + + + + + + + + + + + diff --git a/Sources/Tests/ProjectV.Models.Tests/ValueObjects/JobIdTests.cs b/Sources/Tests/ProjectV.Models.Tests/ValueObjects/JobIdTests.cs new file mode 100644 index 00000000..ca626f47 --- /dev/null +++ b/Sources/Tests/ProjectV.Models.Tests/ValueObjects/JobIdTests.cs @@ -0,0 +1,190 @@ +using System; +using AwesomeAssertions; +using ProjectV.Models.Internal.Jobs; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Generators.Models; +using Xunit; + +namespace ProjectV.Models.Tests.ValueObjects +{ + /// + /// Unit tests for the value-object — exercises + /// Create, Wrap, Parse, TryParse, None, + /// and IsSpecified. Uses for raw + /// inputs. + /// + [Trait("Category", "Unit")] + public sealed class JobIdTests : BaseMockTest + { + private readonly JobIdGenerator _generator; + + public JobIdTests() + { + _generator = new JobIdGenerator(); + } + + [Fact] + public void NoneIsEqualToDefault() + { + // Arrange. + JobId @default = default; + + // Act. + JobId none = JobId.None; + + // Assert. + none.Should().Be(@default); + none.Value.Should().Be(Guid.Empty); + } + + [Fact] + public void NoneIsSpecifiedReturnsFalse() + { + // Arrange. / Act. + bool isSpecified = JobId.None.IsSpecified; + + // Assert. + isSpecified.Should().BeFalse(); + } + + [Fact] + public void CreateReturnsSpecifiedNonEmptyId() + { + // Arrange. / Act. + var jobId = JobId.Create(); + + // Assert. + jobId.IsSpecified.Should().BeTrue(); + jobId.Value.Should().NotBe(Guid.Empty); + jobId.Should().NotBe(JobId.None); + } + + [Fact] + public void CreateReturnsDistinctIdsOnEachCall() + { + // Arrange. / Act. + var first = JobId.Create(); + var second = JobId.Create(); + + // Assert. + first.Should().NotBe(second); + } + + [Fact] + public void WrapWithNonEmptyGuidReturnsSpecifiedId() + { + // Arrange. + var raw = Guid.NewGuid(); + + // Act. + var jobId = JobId.Wrap(raw); + + // Assert. + jobId.Value.Should().Be(raw); + jobId.IsSpecified.Should().BeTrue(); + } + + [Fact] + public void WrapWithEmptyGuidThrowsArgumentException() + { + // Arrange. + var act = () => JobId.Wrap(Guid.Empty); + + // Act. / Assert. + act.Should().Throw() + .WithParameterName("id"); + } + + [Fact] + public void ParseRoundTripsThroughGenerator() + { + // Arrange. + JobId expected = _generator.GenerateJobId(); + string raw = expected.Value.ToString(); + + // Act. + var actual = JobId.Parse(raw); + + // Assert. + actual.Should().Be(expected); + actual.IsSpecified.Should().BeTrue(); + } + + [Fact] + public void ParseThrowsOnEmptyString() + { + // Arrange. + var act = () => JobId.Parse(string.Empty); + + // Act. / Assert. + act.Should().Throw() + .WithParameterName("rawId"); + } + + [Fact] + public void ParseThrowsOnNullString() + { + // Arrange. + var act = () => + { + _ = JobId.Parse(null!); + }; + + // Act. / Assert. + act.Should().Throw() + .WithParameterName("rawId"); + } + + [Fact] + public void TryParseValidGuidReturnsTrueAndPopulatesResult() + { + // Arrange. + string raw = _generator.GenerateRawId(); + + // Act. + bool success = JobId.TryParse(raw, out JobId result); + + // Assert. + success.Should().BeTrue(); + result.IsSpecified.Should().BeTrue(); + result.Value.Should().Be(Guid.Parse(raw)); + } + + [Fact] + public void TryParseInvalidStringReturnsFalseAndDefault() + { + // Arrange. / Act. + bool success = JobId.TryParse("not-a-guid", out JobId result); + + // Assert. + success.Should().BeFalse(); + result.Should().Be(default(JobId)); + result.IsSpecified.Should().BeFalse(); + } + + [Fact] + public void TryParseNullReturnsFalseAndDefault() + { + // Arrange. / Act. + bool success = JobId.TryParse(null, out JobId result); + + // Assert. + success.Should().BeFalse(); + result.Should().Be(default(JobId)); + } + + [Fact] + public void GeneratorCreateJobIdRoundTripsExplicitRaw() + { + // Arrange. + string raw = _generator.GenerateRawId(); + + // Act. + JobId jobId = _generator.CreateJobId(raw); + + // Assert. + jobId.IsSpecified.Should().BeTrue(); + jobId.Value.Should().Be(Guid.Parse(raw)); + } + } +} diff --git a/Sources/Tests/ProjectV.Models.Tests/ValueObjects/UserIdTests.cs b/Sources/Tests/ProjectV.Models.Tests/ValueObjects/UserIdTests.cs new file mode 100644 index 00000000..55e7ebe3 --- /dev/null +++ b/Sources/Tests/ProjectV.Models.Tests/ValueObjects/UserIdTests.cs @@ -0,0 +1,190 @@ +using System; +using AwesomeAssertions; +using ProjectV.Models.Users; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Generators.Models; +using Xunit; + +namespace ProjectV.Models.Tests.ValueObjects +{ + /// + /// Unit tests for the value-object — exercises + /// Create, Wrap, Parse, TryParse, None, + /// and IsSpecified. Uses for raw + /// inputs. + /// + [Trait("Category", "Unit")] + public sealed class UserIdTests : BaseMockTest + { + private readonly UserIdGenerator _generator; + + public UserIdTests() + { + _generator = new UserIdGenerator(); + } + + [Fact] + public void NoneIsEqualToDefault() + { + // Arrange. + UserId @default = default; + + // Act. + UserId none = UserId.None; + + // Assert. + none.Should().Be(@default); + none.Value.Should().Be(Guid.Empty); + } + + [Fact] + public void NoneIsSpecifiedReturnsFalse() + { + // Arrange. / Act. + bool isSpecified = UserId.None.IsSpecified; + + // Assert. + isSpecified.Should().BeFalse(); + } + + [Fact] + public void CreateReturnsSpecifiedNonEmptyId() + { + // Arrange. / Act. + var userId = UserId.Create(); + + // Assert. + userId.IsSpecified.Should().BeTrue(); + userId.Value.Should().NotBe(Guid.Empty); + userId.Should().NotBe(UserId.None); + } + + [Fact] + public void CreateReturnsDistinctIdsOnEachCall() + { + // Arrange. / Act. + var first = UserId.Create(); + var second = UserId.Create(); + + // Assert. + first.Should().NotBe(second); + } + + [Fact] + public void WrapWithNonEmptyGuidReturnsSpecifiedId() + { + // Arrange. + var raw = Guid.NewGuid(); + + // Act. + var userId = UserId.Wrap(raw); + + // Assert. + userId.Value.Should().Be(raw); + userId.IsSpecified.Should().BeTrue(); + } + + [Fact] + public void WrapWithEmptyGuidThrowsArgumentException() + { + // Arrange. + var act = () => UserId.Wrap(Guid.Empty); + + // Act. / Assert. + act.Should().Throw() + .WithParameterName("id"); + } + + [Fact] + public void ParseRoundTripsThroughGenerator() + { + // Arrange. + UserId expected = _generator.GenerateUserId(); + string raw = expected.Value.ToString(); + + // Act. + var actual = UserId.Parse(raw); + + // Assert. + actual.Should().Be(expected); + actual.IsSpecified.Should().BeTrue(); + } + + [Fact] + public void ParseThrowsOnEmptyString() + { + // Arrange. + var act = () => UserId.Parse(string.Empty); + + // Act. / Assert. + act.Should().Throw() + .WithParameterName("rawId"); + } + + [Fact] + public void ParseThrowsOnNullString() + { + // Arrange. + var act = () => + { + _ = UserId.Parse(null!); + }; + + // Act. / Assert. + act.Should().Throw() + .WithParameterName("rawId"); + } + + [Fact] + public void TryParseValidGuidReturnsTrueAndPopulatesResult() + { + // Arrange. + string raw = _generator.GenerateRawId(); + + // Act. + bool success = UserId.TryParse(raw, out UserId result); + + // Assert. + success.Should().BeTrue(); + result.IsSpecified.Should().BeTrue(); + result.Value.Should().Be(Guid.Parse(raw)); + } + + [Fact] + public void TryParseInvalidStringReturnsFalseAndDefault() + { + // Arrange. / Act. + bool success = UserId.TryParse("not-a-guid", out UserId result); + + // Assert. + success.Should().BeFalse(); + result.Should().Be(default(UserId)); + result.IsSpecified.Should().BeFalse(); + } + + [Fact] + public void TryParseNullReturnsFalseAndDefault() + { + // Arrange. / Act. + bool success = UserId.TryParse(null, out UserId result); + + // Assert. + success.Should().BeFalse(); + result.Should().Be(default(UserId)); + } + + [Fact] + public void GeneratorCreateUserIdRoundTripsExplicitRaw() + { + // Arrange. + string raw = _generator.GenerateRawId(); + + // Act. + UserId userId = _generator.CreateUserId(raw); + + // Assert. + userId.IsSpecified.Should().BeTrue(); + userId.Value.Should().Be(Guid.Parse(raw)); + } + } +} diff --git a/Sources/Tests/ProjectV.OmdbService.Tests/OmdbContractTests.cs b/Sources/Tests/ProjectV.OmdbService.Tests/OmdbContractTests.cs new file mode 100644 index 00000000..36b7eb38 --- /dev/null +++ b/Sources/Tests/ProjectV.OmdbService.Tests/OmdbContractTests.cs @@ -0,0 +1,171 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using AwesomeAssertions; +using ProjectV.Models.Data; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Fixtures; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace ProjectV.OmdbService.Tests +{ + /// + /// Contract-stage tests for . + /// Drives the real OMDbApiNet HTTP pipeline against an in-process + /// that serves recorded JSON fixtures from + /// Sources/Tests/Fixtures/Omdb/. No live API calls; per-adapter + /// failure isolation keeps a misbehaving fixture from cascading into + /// other provider suites. + /// + /// + /// + /// OMDbApiNet 1.3.0's AsyncOmdbClient hard-codes BaseUrl = + /// "http://www.omdbapi.com/?" as a const field (verified via + /// reflection — reflection cannot patch const fields because the + /// value is inlined at compile time). The SDK also + /// instantiates a fresh per call, so there is no + /// per-instance handler seam to inject either. The remaining viable + /// redirection seam is : setting it + /// to a pointing at the WireMock server routes + /// every outbound HTTP request (including OMDb's hardcoded + /// http://www.omdbapi.com/ calls) through WireMock as a forward + /// proxy. WireMock receives the original absolute URL and matches stubs + /// against the request path / host accordingly. + /// + /// + /// Setting is process-global; this + /// suite saves and restores the prior value across the + /// lifecycle so it does not bleed into + /// other test classes in the same assembly. xUnit serialises tests + /// within the same class by default, so the global proxy is owned + /// exclusively by this class for the duration of its run. + /// + /// + [Trait("Category", "Contract")] + public sealed class OmdbContractTests : BaseMockTest, IAsyncLifetime + { + private const string MovieByTitleSuccessFixturePath = "Omdb/movie-by-title-success.json"; + private const string MovieByTitleNotFoundFixturePath = "Omdb/movie-by-title-not-found.json"; + + private readonly WireMockServer _server; + private readonly OmdbClient _sut; + private readonly IWebProxy? _originalDefaultProxy; + + public OmdbContractTests() + { + _server = WireMockServer.Start(); + _originalDefaultProxy = HttpClient.DefaultProxy; + + // The api-key value is irrelevant — WireMock matches by path only + // and the SDK echoes the key into the query string, not into auth + // headers. + _sut = new OmdbClient("test-key"); + } + + public Task InitializeAsync() + { + // Load fixtures + configure stubs FIRST, mutate the process-global + // HttpClient.DefaultProxy LAST. xUnit v2 does NOT call + // DisposeAsync when InitializeAsync throws; if the fixture file + // were missing, mutating the proxy before the load would leak the + // mutation for the rest of the test-runner's lifetime (and leak + // the WireMock port). Doing the throwing work first means any + // failure happens before the global is touched. + // + // Use raw-string body (NOT WithBodyAsJson + JObject.Parse) + // — avoids WireMock.Net serializer / Newtonsoft.Json casing + // conflict. + string successBody = FixtureLoader.LoadJsonFixture(MovieByTitleSuccessFixturePath); + + // OMDb requests land at WireMock with the original absolute URL + // (host = www.omdbapi.com, path = "/"). Stub by path "/" — that is + // what the proxy-forwarded request resolves to. + _server + .Given(Request.Create().WithPath("/").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json; charset=utf-8") + .WithBody(successBody)); + + // WireMockServer.Url is non-null after Start() returns; declared + // string? for the lifecycle-pre-start state. This mutation is the + // last operation in InitializeAsync so a failure earlier in this + // method (e.g. fixture load) cannot leave the global state in a + // half-applied state where the save/restore pair would not run. + string wireMockUrl = _server.Url!; + HttpClient.DefaultProxy = new WebProxy(new Uri(wireMockUrl)); + + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + _sut.Dispose(); + HttpClient.DefaultProxy = _originalDefaultProxy!; + _server.Stop(); + _server.Dispose(); + return Task.CompletedTask; + } + + /// + /// Verifies that drives + /// a real HTTP GET through the forward-proxy seam, deserialises the + /// recorded fixture, and surfaces the OMDb item shape through the + /// mapped (tt0012345 → numeric + /// thing id 12345; populated title; non-zero vote count). + /// + [Fact] + public async Task TryGetItemByTitleAsyncReturnsExpectedMovie() + { + // Arrange. + const string title = "Synthetic Movie"; + const int expectedThingId = 12345; // "tt0012345" → 12345 via mapper. + + // Act. + OmdbMovieInfo? actualValue = await _sut.TryGetItemByTitleAsync(title); + + // Assert. + actualValue.Should().NotBeNull(); + actualValue!.ThingId.Should().Be(expectedThingId); + actualValue.Title.Should().Be("Synthetic Movie"); + actualValue.VoteCount.Should().Be(9876); + _server.LogEntries.Should().HaveCount(1, + "OmdbClient should make exactly one HTTP request for a successful by-title fetch " + + "(no internal retry on a 200 response)"); + } + + /// + /// Verifies that an OMDb Response: "False" envelope (the API's + /// not-found shape, returned over HTTP 200) is short-circuited by the + /// production wrapper into a null return — preserving the + /// existing OmdbClient contract for the calling pipeline stages. + /// + [Fact] + public async Task TryGetItemByTitleAsyncNotFoundReturnsNull() + { + // Arrange — override the success stub with the not-found envelope. + // OMDb's not-found is a HTTP 200 with Response: "False". + _server.Reset(); + string notFoundBody = FixtureLoader.LoadJsonFixture(MovieByTitleNotFoundFixturePath); + _server + .Given(Request.Create().WithPath("/").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json; charset=utf-8") + .WithBody(notFoundBody)); + + // Act. + OmdbMovieInfo? actualValue = await _sut.TryGetItemByTitleAsync("no-such-title"); + + // Assert. + actualValue.Should().BeNull( + "OmdbClient should short-circuit Response:\"False\" envelopes into null"); + _server.LogEntries.Should().HaveCount(1, + "OmdbClient should make exactly one HTTP request for the not-found path"); + } + } +} diff --git a/Sources/Tests/ProjectV.OmdbService.Tests/ProjectV.OmdbService.Tests.csproj b/Sources/Tests/ProjectV.OmdbService.Tests/ProjectV.OmdbService.Tests.csproj new file mode 100644 index 00000000..4a43b111 --- /dev/null +++ b/Sources/Tests/ProjectV.OmdbService.Tests/ProjectV.OmdbService.Tests.csproj @@ -0,0 +1,39 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.OmdbService.Tests + false + false + + + + + + + + + + + + + PreserveNewest + Fixtures\Omdb\%(RecursiveDir)%(Filename)%(Extension) + + + + diff --git a/Sources/Tests/ProjectV.OutputProcessing.Tests/OutputManagerTests.cs b/Sources/Tests/ProjectV.OutputProcessing.Tests/OutputManagerTests.cs new file mode 100644 index 00000000..60c16cd0 --- /dev/null +++ b/Sources/Tests/ProjectV.OutputProcessing.Tests/OutputManagerTests.cs @@ -0,0 +1,170 @@ +using System; +using AutoFixture; +using AwesomeAssertions; +using ProjectV.DataPipeline; +using ProjectV.IO.Output; +using ProjectV.Tests.Shared.ForTests; +using Xunit; + +namespace ProjectV.OutputProcessing.Tests +{ + /// + /// Unit tests for 's public contract: + /// returns a non-null + /// regardless of whether the manager has + /// any registered outputters, the storage-name argument is empty, or + /// both. Also covers the constructor null/whitespace guard and the + /// Add / Remove registration round-trip. + /// + /// + /// Collaborator instances are supplied through + /// NSubstitute; the manager itself is the real concrete type. The + /// static _logger field on + /// is initialised through + /// LoggerFactory.CreateLoggerFor<OutputManager>() — the + /// hoisted ProjectV.Tests.Shared.ForTests.TestModuleInitializer + /// installs an empty NLog config on assembly load so the type + /// initialiser does not write log files during the test run. + /// + [Trait("Category", "Unit")] + public sealed class OutputManagerTests : BaseMockTest + { + private const string DefaultStorageName = "default-storage.csv"; + + public OutputManagerTests() + { + } + + [Fact] + public void CreateFlow_ReturnsNonNullFlow() + { + // Arrange. + var sut = BuildSut(); + IOutputter outputter = CreateOutputter(); + sut.Add(outputter); + + // Act. + OutputtersFlow actual = sut.CreateFlow("storage.csv"); + + // Assert. + actual.Should().NotBeNull( + "OutputManager.CreateFlow must return a non-null OutputtersFlow " + + "so the downstream DataflowPipeline can wire the outputters stage" + ); + } + + [Fact] + public void CreateFlow_WithNoOutputters_ReturnsNonNullFlow() + { + // Arrange. + var sut = BuildSut(); + + // Act. + OutputtersFlow actual = sut.CreateFlow("storage.csv"); + + // Assert. + actual.Should().NotBeNull( + "the contract holds even with zero outputters — Shell wires the " + + "flow before any outputter is necessarily registered. The flow's " + + "default Action logs each result; concrete " + + "outputters are consumed later by SaveResults()." + ); + } + + [Fact] + public void CreateFlow_WithEmptyStorageName_FallsBackToDefaultAndReturnsNonNullFlow() + { + // Arrange. + var sut = BuildSut(); + sut.Add(CreateOutputter()); + + // Act. + OutputtersFlow actual = sut.CreateFlow(string.Empty); + + // Assert. + actual.Should().NotBeNull( + "an empty storage name must fall back to the default storage name " + + "without breaking the flow construction" + ); + } + + [Fact] + public void Constructor_WithNullDefaultStorageName_ThrowsArgumentNullException() + { + // Arrange. / Act. + var act = () => new OutputManager( + defaultStorageName: null! + ); + + // Assert. + act.Should() + .Throw() + .WithParameterName("defaultStorageName"); + } + + [Fact] + public void Constructor_WithWhitespaceDefaultStorageName_ThrowsArgumentException() + { + // Arrange. / Act. + var act = () => new OutputManager(defaultStorageName: " "); + + // Assert. + act.Should() + .Throw() + .WithParameterName("defaultStorageName"); + } + + [Fact] + public void Add_WithNullOutputter_ThrowsArgumentNullException() + { + // Arrange. + var sut = BuildSut(); + + // Act. + var act = () => sut.Add( + item: null! + ); + + // Assert. + act.Should() + .Throw() + .WithParameterName("item"); + } + + [Fact] + public void Remove_WithRegisteredOutputter_ReturnsTrue() + { + // Arrange. + var sut = BuildSut(); + IOutputter outputter = CreateOutputter(); + sut.Add(outputter); + + // Act. + bool removed = sut.Remove(outputter); + + // Assert. + removed.Should().BeTrue( + "Remove must report success when the manager holds the supplied outputter" + ); + } + + /// + /// Creates a bare substitute via the shared + /// . Centralises substitute creation + /// so test bodies do not call Fixture.Create directly. + /// + private IOutputter CreateOutputter() + { + return Fixture.Create(); + } + + /// + /// Builds a default-storage SUT. + /// Per-class helper to keep test bodies focused on Arrange/Act/Assert. + /// + private static OutputManager BuildSut() + { + return new OutputManager(DefaultStorageName); + } + } +} diff --git a/Sources/Tests/ProjectV.OutputProcessing.Tests/ProjectV.OutputProcessing.Tests.csproj b/Sources/Tests/ProjectV.OutputProcessing.Tests/ProjectV.OutputProcessing.Tests.csproj new file mode 100644 index 00000000..891cc80c --- /dev/null +++ b/Sources/Tests/ProjectV.OutputProcessing.Tests/ProjectV.OutputProcessing.Tests.csproj @@ -0,0 +1,26 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.OutputProcessing.Tests + false + false + + + + + + + + + + diff --git a/Sources/Tests/ProjectV.SteamService.Tests/ProjectV.SteamService.Tests.csproj b/Sources/Tests/ProjectV.SteamService.Tests/ProjectV.SteamService.Tests.csproj new file mode 100644 index 00000000..af68fb4f --- /dev/null +++ b/Sources/Tests/ProjectV.SteamService.Tests/ProjectV.SteamService.Tests.csproj @@ -0,0 +1,39 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.SteamService.Tests + false + false + + + + + + + + + + + + + PreserveNewest + Fixtures\Steam\%(RecursiveDir)%(Filename)%(Extension) + + + + diff --git a/Sources/Tests/ProjectV.SteamService.Tests/SteamContractTests.cs b/Sources/Tests/ProjectV.SteamService.Tests/SteamContractTests.cs new file mode 100644 index 00000000..5adf57a5 --- /dev/null +++ b/Sources/Tests/ProjectV.SteamService.Tests/SteamContractTests.cs @@ -0,0 +1,202 @@ +using System.Reflection; +using System.Threading.Tasks; +using AwesomeAssertions; +using ProjectV.Models.Data; +using ProjectV.SteamService.Models; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Fixtures; +using SteamWebApiLib; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +// Disambiguate: ProjectV's SteamApiClient is the SUT; SteamWebApiLib's is the +// internal third-party SDK. Both live in their respective namespaces; we alias +// to keep call sites readable. +using ProjectVSteamApiClient = ProjectV.SteamService.SteamApiClient; +using SdkSteamApiClient = SteamWebApiLib.SteamApiClient; + +namespace ProjectV.SteamService.Tests +{ + /// + /// Contract-stage tests for . + /// Drives the real SteamWebApiLib HTTP pipeline against an in-process + /// that serves recorded JSON fixtures from + /// Sources/Tests/Fixtures/Steam/. No live API calls; per-adapter + /// failure isolation keeps a misbehaving fixture from cascading into + /// other provider suites. + /// + /// + /// exposes writable + /// SteamPoweredBaseUrl and SteamStoreBaseUrl properties — + /// pointing both at the WireMock server's URL routes the GetAppList + /// (api.steampowered.com → /ISteamApps/GetAppList/v0002/) and the + /// appdetails (store.steampowered.com → /api/appdetails) + /// endpoints to the local stub. The production wrapper's single-arg ctor + /// builds its own internally, so we use + /// reflection on the private _steamApiClient field to replace the + /// SDK instance with one built from a config whose base URLs point at + /// WireMock. RetryAttempts is set to 0 so the HTTP log-entry count + /// assertion can rely on exactly-once semantics on the success path. + /// + [Trait("Category", "Contract")] + public sealed class SteamContractTests : BaseMockTest, IAsyncLifetime + { + private const int ExpectedAppId = 730; + private const string AppListFixturePath = "Steam/app-list-success.json"; + private const string AppDetailFixturePath = "Steam/app-detail-success.json"; + + private readonly WireMockServer _server; + private readonly ProjectVSteamApiClient _sut; + + public SteamContractTests() + { + _server = WireMockServer.Start(); + + // Build a config whose base URLs point at WireMock. RetryAttempts=0 + // keeps the HTTP-call counts deterministic for the exactly-once + // log-entry assertion. + var overriddenConfig = new SteamApiConfig + { + ApiKey = "test-key", + SteamPoweredBaseUrl = _server.Url, + SteamStoreBaseUrl = _server.Url, + RetryAttempts = 0 + }; + + // ProjectVSteamApiClient ctor builds its own SteamApiConfig from + // the api-key alone; replace the internal SDK instance with one + // built from our overridden config (InternalsVisibleTo grants + // access to the internal sealed type). + _sut = new ProjectVSteamApiClient("test-key"); + ReplaceInternalSdkClient(overriddenConfig); + } + + public Task InitializeAsync() + { + // Stub /ISteamApps/GetAppList/v0002/ GET → recorded app list. + // Use raw-string body (NOT WithBodyAsJson + JObject.Parse) + // — avoids WireMock.Net serializer / Newtonsoft.Json casing + // conflict. + string appList = FixtureLoader.LoadJsonFixture(AppListFixturePath); + _server + .Given(Request.Create().WithPath("/ISteamApps/GetAppList/v0002/").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json; charset=utf-8") + .WithBody(appList)); + + // Stub /api/appdetails GET → recorded app-detail envelope. + string appDetail = FixtureLoader.LoadJsonFixture(AppDetailFixturePath); + _server + .Given(Request.Create().WithPath("/api/appdetails").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json; charset=utf-8") + .WithBody(appDetail)); + + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + _sut.Dispose(); + _server.Stop(); + _server.Dispose(); + return Task.CompletedTask; + } + + /// + /// Verifies that + /// drives a real HTTP GET against + /// /ISteamApps/GetAppList/v0002/, deserialises the recorded + /// fixture, and surfaces the brief-info container through the mapped + /// . The fixture pins 3 entries + /// and a sentinel app-id 730 for the first row. + /// + [Fact] + public async Task GetAppListAsyncReturnsExpectedApps() + { + // Act. + SteamBriefInfoContainer actualValue = await _sut.GetAppListAsync(); + + // Assert. + actualValue.Should().NotBeNull(); + actualValue.Results.Should().HaveCount(3); + actualValue.Results[0].AppId.Should().Be(ExpectedAppId); + actualValue.Results[0].Name.Should().NotBeNullOrWhiteSpace(); + _server.LogEntries.Should().HaveCount(1, + "SteamApiClient should make exactly one HTTP request for a successful app-list fetch " + + "(no internal retry on a 200 response, RetryAttempts=0)"); + } + + /// + /// Verifies that + /// drives a + /// real HTTP GET against /api/appdetails, deserialises the + /// recorded fixture, and surfaces the app envelope (success: true, + /// data: SteamApp) through the mapped . + /// The fixture pins steam_appid=730; the mapped + /// ThingId matches. + /// + [Fact] + public async Task TryGetSteamAppAsyncReturnsExpectedApp() + { + // Arrange. + const SteamCountryCode countryCode = SteamCountryCode.USA; + const SteamResponseLanguage language = SteamResponseLanguage.English; + + // Act. + SteamGameInfo? actualValue = await _sut.TryGetSteamAppAsync( + ExpectedAppId, countryCode, language); + + // Assert. + actualValue.Should().NotBeNull(); + actualValue!.ThingId.Should().Be(ExpectedAppId); + actualValue.Title.Should().NotBeNullOrWhiteSpace(); + actualValue.RequiredAge.Should().BeGreaterThanOrEqualTo(0); + actualValue.PosterPath.Should().NotBeNullOrWhiteSpace(); + _server.LogEntries.Should().HaveCount(1, + "SteamApiClient should make exactly one HTTP request for a successful by-id fetch " + + "(no internal retry on a 200 response, RetryAttempts=0)"); + } + + /// + /// Replaces the third-party + /// instance held by the production + /// wrapper with one built from a whose + /// base URLs point at WireMock. The production + /// ctor accepts only an + /// api-key and builds its own config internally, so the overridden + /// base URLs cannot reach the SDK without reflection on the private + /// SDK instance field. + /// + private void ReplaceInternalSdkClient(SteamApiConfig overriddenConfig) + { + // FRAGILE: private-field reflection seam. If SteamWebApiLib renames + // _steamApiClient, converts it to a property, or changes the + // SdkSteamApiClient ctor surface, the assertion below fires at + // runtime (not compile time) and this contract suite breaks. This + // fragility is accepted because ProjectVSteamApiClient's + // single-arg ctor builds its own SdkSteamApiClient internally — + // there is no public seam to inject a SteamApiConfig pointing at + // WireMock. The non-fragile fix is a ctor overload on + // ProjectVSteamApiClient that accepts a pre-built SteamApiConfig; + // until then, watch for SDK upgrade breakage on this line. + FieldInfo? sdkFieldInfo = typeof(ProjectVSteamApiClient).GetField( + "_steamApiClient", + BindingFlags.NonPublic | BindingFlags.Instance + ); + sdkFieldInfo.Should().NotBeNull( + "ProjectV.SteamService.SteamApiClient must hold the SDK client in _steamApiClient " + + "for the contract-test seam to redirect outbound calls to WireMock"); + + // Dispose the SDK instance built by the production ctor (which + // pointed at the live Steam endpoints) before replacing it so we + // do not leak the HttpClient it owns. + (sdkFieldInfo!.GetValue(_sut) as SdkSteamApiClient)?.Dispose(); + sdkFieldInfo.SetValue(_sut, new SdkSteamApiClient(overriddenConfig)); + } + } +} diff --git a/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Helpers/Stubs/Telegram/StubBotService.cs b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Helpers/Stubs/Telegram/StubBotService.cs new file mode 100644 index 00000000..61697cdb --- /dev/null +++ b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Helpers/Stubs/Telegram/StubBotService.cs @@ -0,0 +1,171 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Acolyte.Assertions; +using ProjectV.TelegramBotWebService.v1.Domain.Bot; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace ProjectV.TelegramBotWebService.Tests.Helpers.Stubs.Telegram +{ + /// + /// Scenario-test stub for . Owns a real (or + /// fake) and returns deterministic + /// no-op completions for the API surface exercised by scenario tests. + /// Per the create-tests scenario rules, scenario tests must use + /// stubs (named Stub{DependencyName}) rather than NSubstitute + /// mocks for types they own. + /// + /// + /// This stub records the name of every method + /// that the production code invokes, in invocation order. Scenario tests + /// use to assert on the production + /// handler chain's interaction shape without relying on NSubstitute's + /// call-tracking API. The underlying is + /// provided by the test composition root and is not disposed here. Because + /// the polling path can drive SendMessageAsync from concurrent + /// handler invocations, the call list is protected by a lock so that + /// invocation order is deterministic and no records are lost. + /// + public sealed class StubBotService : IBotService + { + private readonly object _callsLock = new(); + + private readonly List _calledMethodNames = new(); + + /// + /// Gets a snapshot of the method names + /// that the production code has invoked on this stub, in invocation + /// order. Scenario tests read this property to assert on the + /// interaction shape of the production handler chain (e.g., that + /// SendMessageAsync was called the expected number of times). + /// The returned list is a point-in-time copy; it does not update after + /// capture. + /// + public IReadOnlyList CalledMethodNames + { + get + { + lock (_callsLock) + { + return _calledMethodNames.AsReadOnly(); + } + } + } + + /// + public ITelegramBotClient BotClient { get; } + + /// + /// Initializes a new instance of the + /// class with the supplied bot client. + /// + /// + /// The that this stub exposes via + /// . Must not be null. + /// + public StubBotService(ITelegramBotClient botClient) + { + BotClient = botClient.ThrowIfNull(nameof(botClient)); + } + + /// + /// + /// Scenario tests drive update delivery through the bot-client + /// stub's GetUpdates implementation, not through this method. + /// Returns an empty list as a deterministic no-op default. + /// + public Task> GetUpdatesAsync( + int? offset = default, + int? limit = default, + int? timeout = default, + IEnumerable? allowedUpdates = default, + CancellationToken cancellationToken = default) + { + lock (_callsLock) + { + _calledMethodNames.Add(nameof(GetUpdatesAsync)); + } + + return Task.FromResult>(System.Array.Empty()); + } + + /// + public Task SetWebhookAsync( + string url, + InputFileStream? certificate = default, + string? ipAddress = default, + int? maxConnections = default, + IEnumerable? allowedUpdates = default, + bool dropPendingUpdates = default, + CancellationToken cancellationToken = default) + { + lock (_callsLock) + { + _calledMethodNames.Add(nameof(SetWebhookAsync)); + } + + return Task.CompletedTask; + } + + /// + public Task DeleteWebhookAsync( + bool dropPendingUpdates = default, + CancellationToken cancellationToken = default) + { + lock (_callsLock) + { + _calledMethodNames.Add(nameof(DeleteWebhookAsync)); + } + + return Task.CompletedTask; + } + + /// + public Task GetWebhookInfoAsync( + CancellationToken cancellationToken = default) + { + lock (_callsLock) + { + _calledMethodNames.Add(nameof(GetWebhookInfoAsync)); + } + + return Task.FromResult(new WebhookInfo()); + } + + /// + public Task SendMessageAsync( + ChatId chatId, + string text, + ParseMode parseMode = default, + ReplyParameters? replyParameters = default, + ReplyMarkup? replyMarkup = default, + LinkPreviewOptions? linkPreviewOptions = default, + int? messageThreadId = default, + IEnumerable? entities = default, + bool disableNotification = default, + bool protectContent = default, + string? messageEffectId = default, + string? businessConnectionId = default, + bool allowPaidBroadcast = default, + CancellationToken cancellationToken = default) + { + lock (_callsLock) + { + _calledMethodNames.Add(nameof(SendMessageAsync)); + } + + return Task.FromResult(new Message()); + } + + /// + public void Dispose() + { + // Stub is stateless from a resource-ownership perspective — + // the underlying ITelegramBotClient is owned by the test + // composition root, not this stub. + } + } +} diff --git a/Sources/Tests/ProjectV.TelegramBotWebService.Tests/ProjectV.TelegramBotWebService.Tests.csproj b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/ProjectV.TelegramBotWebService.Tests.csproj new file mode 100644 index 00000000..b043b19a --- /dev/null +++ b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/ProjectV.TelegramBotWebService.Tests.csproj @@ -0,0 +1,37 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.TelegramBotWebService.Tests + false + false + + + + + + + + + + + + + + + + diff --git a/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Polling/TelegramPollingProcessesUpdateSequenceTests.cs b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Polling/TelegramPollingProcessesUpdateSequenceTests.cs new file mode 100644 index 00000000..c80861fb --- /dev/null +++ b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Polling/TelegramPollingProcessesUpdateSequenceTests.cs @@ -0,0 +1,192 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using ProjectV.TelegramBotWebService.v1.Domain.Bot; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Mocks.Telegram; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Xunit; + +namespace ProjectV.TelegramBotWebService.Tests.Scenarios.Polling +{ + /// + /// Scenario TG-POLL-1: the production PoolingProcessor hosted + /// service drains a fixed sequence of objects from + /// the substituted ITelegramBotClient and forwards each one + /// through the full handler chain (BotPollingUpdateHandler → + /// UpdateService.HandleUpdateAsyncBotMessageHandler → + /// IBotService.SendMessageAsync). + /// + /// + /// The bot-client substitute is built via + /// + /// — the first poll yields the configured updates, every subsequent poll + /// yields an empty array, and the long-polling loop exits when the host's + /// cancellation token signals (the test stops the host explicitly inside + /// the act-phase polling loop). The assertion proves the polling half + /// of the Telegram coverage: the WithUpdateSequence(...) builder + /// is consumed end-to-end by the polling hosted service and every + /// update reaches IBotService.SendMessageAsync. + /// + [Trait("Category", "Integration")] + public sealed class TelegramPollingProcessesUpdateSequenceTests + : TelegramPollingScenarioBaseTest + { + // The scripted update sequence: three text-message updates with + // commands the BotMessageHandler routes to SendMessageAsync. /start + // and /help land on direct SendMessage replies; the freeform + // "Hello there" lands on SendResponseToInvalidMessage (which is + // also a SendMessage call). Net effect: three SendMessage calls + // through the production handler chain. + private const int ExpectedUpdateCount = 3; + + /// + /// Initializes a new instance of the + /// class. + /// + public TelegramPollingProcessesUpdateSequenceTests() + : base( + botClientStub: new TestTelegramBotClientBuilder(BaseMockTest.CreateFixture()) + .WithUpdateSequence(BuildUpdateSequence()) + .Build()) + { + } + + /// + /// Scenario TG-POLL-1: the polling hosted service drains the scripted + /// update sequence and the production handler chain calls + /// IBotService.SendMessageAsync at least once per update. + /// + [Fact] + public async Task PoolingProcessor_ProcessesFixedUpdateSequence_ForwardsToBotServiceSendMessage() + { + // Arrange. + // The base ctor has already supplied the bot-client substitute + // (pre-loaded with three Updates via WithUpdateSequence) and + // the bot-service substitute. WebApiBaseTest.InitializeAsync + // built the TestWebApplicationFactory and called CreateClient(), + // which triggers IHost.StartAsync() — at this point the host has + // resolved PoolingProcessor (BackgroundService) and is running + // its receive loop in the background. + // + // The receive loop: + // 1. Calls IBotService.DeleteWebhookAsync (stubbed → completes). + // 2. Calls IBotService.BotClient.ReceiveAsync(handler, opts, ct). + // 3. ReceiveAsync internally calls + // BotClient.SendRequest(new GetUpdatesRequest{...}, ct). + // The substitute (configured via WithUpdateSequence) yields + // the three updates on the first call and empty arrays + // thereafter. + // 4. For each update, the receiver invokes + // BotPollingUpdateHandler.HandleUpdateAsync(client, update, ct) + // → UpdateService.HandleUpdateAsync(update, ct) + // → BotMessageHandler.ProcessAsync(message, ct) + // → IBotService.SendMessageAsync(chatId, text, ..., ct). + + // Act. + // Wait for the polling loop to drain the scripted updates with + // a bounded timeout — prevents the test from hanging if the + // receive loop is misconfigured. + using var timeoutSource = new CancellationTokenSource( + TimeSpan.FromSeconds(15)); + await WaitForExpectedSendMessageCountAsync( + ExpectedUpdateCount, timeoutSource.Token); + + // Assert. + BotServiceStub.CalledMethodNames + .Should() + .NotBeEmpty( + "the polling loop must have forwarded at least one " + + "update through the production handler chain. " + + $"NLog captured: {string.Join(Environment.NewLine, Webhook.CapturedException.LogLines)}"); + + int sendMessageCallCount = CountSendMessageCalls(); + sendMessageCallCount.Should().BeGreaterThanOrEqualTo( + ExpectedUpdateCount, + $"the polling loop must drain all {ExpectedUpdateCount} scripted " + + "updates and the production handler chain must call " + + "IBotService.SendMessageAsync at least once per update. " + + $"NLog captured: {string.Join(Environment.NewLine, Webhook.CapturedException.LogLines)}"); + } + + // The Update sequence the scripted-bot-client yields on the first + // poll. Three text-message updates with sequential Ids. Every + // command lands on a BotMessageHandler branch that calls + // IBotService.SendMessageAsync exactly once. + private static Update[] BuildUpdateSequence() + { + return new[] + { + BuildTextMessageUpdate(updateId: 100, messageId: 1, chatId: 999L, text: "/start"), + BuildTextMessageUpdate(updateId: 101, messageId: 2, chatId: 999L, text: "/help"), + BuildTextMessageUpdate(updateId: 102, messageId: 3, chatId: 999L, text: "Hello there"), + }; + } + + private static Update BuildTextMessageUpdate( + int updateId, int messageId, long chatId, string text) + { + return new Update + { + Id = updateId, + Message = new Message + { + Id = messageId, + Text = text, + Chat = new Chat + { + Id = chatId, + Type = ChatType.Private + }, + From = new User + { + Id = chatId, + FirstName = "Test", + IsBot = false + } + } + }; + } + + private async Task WaitForExpectedSendMessageCountAsync( + int target, CancellationToken cancellationToken) + { + // Poll the substitute's call count until it reaches the target + // or the cancellation token signals. The polling delay is small + // because the receive loop runs in-process and is fast; the + // bounded timeout (15 s) absorbs CI slowness without making the + // test fragile on a fast machine. + while (!cancellationToken.IsCancellationRequested) + { + if (CountSendMessageCalls() >= target) + { + return; + } + + try + { + await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); + } + catch (TaskCanceledException) + { + // Cancellation reached during the delay — fall through + // so the assertion can read the final count. + return; + } + } + } + + private int CountSendMessageCalls() + { + // BotServiceStub.CalledMethodNames includes every IBotService call + // (including the DeleteWebhookAsync call at the start of the polling + // loop). Filter to SendMessageAsync so the count reflects only the + // handler-chain end-state. + return BotServiceStub.CalledMethodNames + .Count(name => name == nameof(IBotService.SendMessageAsync)); + } + } +} diff --git a/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Polling/TelegramPollingScenarioBaseTest.cs b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Polling/TelegramPollingScenarioBaseTest.cs new file mode 100644 index 00000000..88a03f9a --- /dev/null +++ b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Polling/TelegramPollingScenarioBaseTest.cs @@ -0,0 +1,227 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ProjectV.Core.Services.Clients; +using ProjectV.TelegramBotWebService.Options; +using ProjectV.TelegramBotWebService.Tests.Helpers.Stubs.Telegram; +using ProjectV.TelegramBotWebService.Tests.Scenarios.Webhook; +using ProjectV.TelegramBotWebService.v1.Domain.Bot; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Mocks.Core; +using ProjectV.Tests.Shared.Helpers.Mocks.Telegram; +using Telegram.Bot; + +namespace ProjectV.TelegramBotWebService.Tests.Scenarios.Polling +{ + /// + /// Per-family base class for Telegram polling scenario tests against + /// ProjectV.TelegramBotWebService. Sibling to + /// . + /// + /// + /// + /// The polling path differs from the webhook path in two ways. First, the + /// production PoolingProcessor is a BackgroundService the + /// host starts when the working mode is + /// . + /// The processor calls IBotPolling.StartReceivingUpdatesAsync, which + /// in turn calls IBotService.DeleteWebhookAsync and then + /// IBotService.BotClient.ReceiveAsync(...) — the production polling + /// loop. Second, the test asserts on the in-process call-count recorded by + /// the (one SendMessageAsync call per + /// update the production handler chain drains) rather than on an HTTP + /// response, because the polling path has no outbound HTTP response + /// surface. + /// + /// + /// Like the webhook base, this base class: + /// + /// + /// Removes the production + /// singleton and re-registers a + /// whose BotClient property returns + /// the supplied stub. The stub handles + /// DeleteWebhookAsync (no-op) and records every + /// SendMessageAsync invocation in + /// so the handler-chain + /// assertion can read the call count deterministically without + /// NSubstitute. + /// Removes the production + /// transient and re-registers a + /// no-setup + /// + /// substitute. The polling path's + /// BotMessageHandler branch for non-/request commands does + /// not reach the comm-client, but the DI graph wires it transitively so + /// the production CommunicationServiceClient ctor (which + /// validates a strict options chain) must be kept out of the test path. + /// Sets the host's + /// TelegramBotWebServiceOptions:WorkingMode to + /// + /// so the host registers and starts PoolingProcessor as a + /// BackgroundService when + /// is built. The PoolingProcessor factory + /// (PoolingProcessor.Create) resolves IBotPolling from the + /// container at host start; BotPolling's ctor pulls + /// IBotService, which by then is the test-side stub (the test + /// override registered in ConfigureTestServices runs AFTER + /// Startup.ConfigureServices but BEFORE the host starts its + /// IHostedService instances, so the substitution wins). + /// Supplies a non-empty dummy Bot:Token so + /// BotOptions.Validate() passes on first + /// IOptions<TelegramBotWebServiceOptions>.Value access. The + /// dummy token is never used because IBotService is replaced. + /// + /// + /// The bot-client stub is exposed as so + /// derived scenarios can build it via + /// + /// and assert on outgoing SendRequest calls if needed. The + /// is exposed as + /// so scenarios can assert on the production handler chain's downstream + /// calls via BotServiceStub.CalledMethodNames. + /// + /// + public abstract class TelegramPollingScenarioBaseTest : WebApiBaseTest + { + /// + /// Gets the stub the host's + /// exposes via its BotClient + /// property. Typically built via + /// + /// in the derived ctor. + /// + protected ITelegramBotClient BotClientStub { get; } + + /// + /// Gets the the host resolves in place + /// of the production singleton. Derived scenarios can assert on + /// BotServiceStub.CalledMethodNames to verify which + /// methods the production handler chain + /// invoked and how many times (e.g., count entries equal to + /// nameof(IBotService.SendMessageAsync) to confirm the + /// expected number of updates were drained). + /// + protected StubBotService BotServiceStub { get; } + + /// + /// Initializes a new instance of the + /// class. + /// + /// + /// Optional pre-built substitute. + /// When null, a bare + /// stub + /// is used (the polling loop will fetch an empty batch on the first + /// call and keep looping until cancellation — useful only for tests + /// that do not assert on update consumption). + /// + /// + /// Optional in-memory configuration overrides layered on top of the + /// host's appsettings.json. The base class always layers a + /// WorkingMode=PollingViaHostedService override plus a dummy + /// BotToken override. + /// + protected TelegramPollingScenarioBaseTest( + ITelegramBotClient? botClientStub = null, + IReadOnlyDictionary? extraConfiguration = null) + : this( + resolvedBotClientStub: new ResolvedBotStubs( + botClientStub ?? TestTelegramBotClientBuilder.CreateWithoutSetup(BaseMockTest.CreateFixture())), + extraConfiguration: extraConfiguration) + { + } + + // Tiny wrapper so the protected ctor's signature does not collide with + // a hypothetical future overload and so we can capture the supplied + // (or default) bot-client + freshly-built bot-service substitutes + // exactly once for both the DI override AND the protected properties. + private TelegramPollingScenarioBaseTest( + ResolvedBotStubs resolvedBotClientStub, + IReadOnlyDictionary? extraConfiguration) + : base( + jwtConfig: null, + extraConfiguration: BuildConfiguration(extraConfiguration), + configureTestServices: services => + ConfigureBotServiceSwap(services, resolvedBotClientStub)) + { + BotClientStub = resolvedBotClientStub.Client; + BotServiceStub = resolvedBotClientStub.Service; + } + + // Holder so the resolved bot-client + bot-service stubs can be + // captured before the base ctor runs and re-used by both the + // configureTestServices delegate and the protected properties. + private readonly record struct ResolvedBotStubs( + ITelegramBotClient Client, + StubBotService Service) + { + public ResolvedBotStubs(ITelegramBotClient client) + : this(client, BuildBotServiceStub(client)) + { + } + + private static StubBotService BuildBotServiceStub( + ITelegramBotClient client) + { + return new StubBotService(client); + } + } + + /// + public override Task InitializeAsync() + { + CapturedException.EnsureNLogMemoryTarget(); + CapturedException.Clear(); + return base.InitializeAsync(); + } + + private static void ConfigureBotServiceSwap( + IServiceCollection services, + ResolvedBotStubs resolved) + { + services.RemoveAll(); + services.AddSingleton(resolved.Service); + + // Same rationale as the webhook base class: the production + // CommunicationServiceClient validates a strict options chain + // that is not satisfied by the test configuration. The polling + // BotMessageHandler branches we exercise do not reach the + // comm-client, but a transient dep of UpdateService / + // BotMessageHandler resolves it eagerly when the singleton + // graph is built. + services.RemoveAll(); + services.AddSingleton(TestCommunicationServiceClientBuilder.CreateWithoutSetup(BaseMockTest.CreateFixture())); + } + + private static IReadOnlyDictionary BuildConfiguration( + IReadOnlyDictionary? extra) + { + var merged = new Dictionary + { + // Polling-via-hosted-service is the working mode under test. + // The host registers PoolingProcessor as a BackgroundService + // and starts it when IHost.StartAsync() runs (which the + // WebApplicationFactory triggers from CreateClient()). + ["TelegramBotWebServiceOptions:WorkingMode"] = + nameof(TelegramBotWebServiceWorkingMode.PollingViaHostedService), + + // Supply a non-empty dummy bot token so the BotOptions + // validation chain doesn't blow up. The token is never + // used because IBotService is replaced. + ["TelegramBotWebServiceOptions:Bot:Token"] = "test-only-dummy-bot-token", + }; + + if (extra is not null) + { + foreach (var kvp in extra) + { + merged[kvp.Key] = kvp.Value; + } + } + + return merged; + } + } +} diff --git a/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/CapturedException.cs b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/CapturedException.cs new file mode 100644 index 00000000..ecf1f365 --- /dev/null +++ b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/CapturedException.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Threading; +using NLog; +using NLog.Config; +using NLog.Targets; + +namespace ProjectV.TelegramBotWebService.Tests.Scenarios.Webhook +{ + /// + /// Temporary diagnostic helper — captures any unhandled exception + /// surfaced by the test host's request pipeline so failing scenario + /// tests can include the full stack in the AwesomeAssertions reason + /// string. The production + /// ExceptionMiddleware swallows the exception details into a + /// generic HTTP 500 body and only logs the stack via NLog, so we + /// attach a to NLog to read those logs + /// back from the test. + /// + /// + /// Used by . The capture + /// is pipeline-local — every test grabs and clears the value at the + /// start of its act phase so a previous test's exception never leaks + /// into the assertion of a later test. + /// + internal static class CapturedException + { + private static readonly object _gate = new object(); + private static MemoryTarget? _memoryTarget; + private static Exception? _last; + + /// + /// Gets the most recently captured pipeline exception, or + /// null if no request has thrown since the last + /// call. + /// + public static Exception? Last => Volatile.Read(ref _last); + + /// + /// Gets a snapshot of every log line captured by the + /// . May be empty if the production + /// code did not log on the request path; this includes NLog + /// formatting plus exception stacks per the layout below. + /// + public static System.Collections.Generic.IReadOnlyList LogLines + { + get + { + lock (_gate) + { + if (_memoryTarget is null) return Array.Empty(); + return _memoryTarget.Logs.ToArray(); + } + } + } + + /// + /// Attaches the diagnostic to the + /// NLog configuration. Safe to call multiple times — the second + /// and later calls are no-ops. + /// + public static void EnsureNLogMemoryTarget() + { + lock (_gate) + { + if (_memoryTarget is not null) return; + + _memoryTarget = new MemoryTarget + { + Name = "projectv-test-capture", + Layout = "${longdate}|${level:uppercase=true}|${logger}|${message}|${exception:format=ToString}" + }; + + var config = LogManager.Configuration ?? new LoggingConfiguration(); + config.AddTarget(_memoryTarget); + config.AddRule(NLog.LogLevel.Warn, NLog.LogLevel.Fatal, _memoryTarget); + LogManager.Configuration = config; + } + } + + /// + /// Captures the supplied exception so it surfaces in failing + /// assertion messages. + /// + /// Exception to capture. + public static void Capture(Exception exception) + { + Volatile.Write(ref _last, exception); + } + + /// + /// Clears the captured exception and any NLog memory-target log + /// lines. Called at the start of each scenario test's act phase. + /// + public static void Clear() + { + Volatile.Write(ref _last, null); + lock (_gate) + { + _memoryTarget?.Logs.Clear(); + } + } + } +} diff --git a/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/TelegramWebhookInvalidPayloadTests.cs b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/TelegramWebhookInvalidPayloadTests.cs new file mode 100644 index 00000000..7b473051 --- /dev/null +++ b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/TelegramWebhookInvalidPayloadTests.cs @@ -0,0 +1,59 @@ +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using AwesomeAssertions; +using Xunit; + +namespace ProjectV.TelegramBotWebService.Tests.Scenarios.Webhook +{ + /// + /// Scenario TG-WEB-2: webhook rejects malformed JSON payloads. + /// + /// + /// The production webhook controller + /// () is decorated + /// with [ApiController] and accepts + /// [FromBody] Telegram.Bot.Types.Update. When the body cannot be + /// deserialised, the [ApiController] automatic-model-state + /// validation short-circuits the pipeline with HTTP 400 before the + /// action runs. The scenario asserts the response is a client error + /// (4xx) — the exact status comes from the production + /// AddNewtonsoftJson model binder, not from any code in the + /// controller, so the test asserts the production behaviour AS-IS + /// rather than dictating a specific 400 versus 415 outcome. + /// + [Trait("Category", "Integration")] + public sealed class TelegramWebhookInvalidPayloadTests : TelegramWebhookScenarioBaseTest + { + /// + /// Initializes a new instance of the + /// class. + /// + public TelegramWebhookInvalidPayloadTests() + { + } + + /// + /// Scenario TG-WEB-2 — POST a malformed JSON body returns a 4xx + /// client error (the auto-model-validation pipeline rejects the + /// payload before the action runs). + /// + [Fact] + public async Task PostUpdate_WithMalformedJson_ReturnsClientError() + { + // Arrange. + const string malformedBody = "{ not valid json"; + using var content = new StringContent(malformedBody, Encoding.UTF8, "application/json"); + + // Act. + using HttpResponseMessage response = await Client.PostAsync( + "/api/v1/Update", content); + + // Assert. + int statusCode = (int) response.StatusCode; + statusCode.Should().BeInRange(400, 499, + "malformed JSON must be rejected by the production model binder " + + "with a 4xx client error before the action runs"); + } + } +} diff --git a/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/TelegramWebhookScenarioBaseTest.cs b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/TelegramWebhookScenarioBaseTest.cs new file mode 100644 index 00000000..ba932da8 --- /dev/null +++ b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/TelegramWebhookScenarioBaseTest.cs @@ -0,0 +1,184 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ProjectV.Core.Services.Clients; +using ProjectV.TelegramBotWebService.Options; +using ProjectV.TelegramBotWebService.Tests.Helpers.Stubs.Telegram; +using ProjectV.TelegramBotWebService.v1.Domain.Bot; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Mocks.Core; +using ProjectV.Tests.Shared.Helpers.Mocks.Telegram; +using ProjectV.Tests.Shared.Helpers.WebApi; +using Telegram.Bot; + +namespace ProjectV.TelegramBotWebService.Tests.Scenarios.Webhook +{ + /// + /// Per-family base class for Telegram webhook scenario tests against + /// ProjectV.TelegramBotWebService. Bundles the + /// wiring + the + /// swap that every webhook scenario relies on. + /// + /// + /// + /// The production BotService ctor constructs a real + /// via + /// new TelegramBotClient(BotToken, HttpClient) — which throws on + /// an empty BotToken. The base class therefore: + /// + /// + /// Removes the production + /// singleton from DI inside + /// ConfigureTestServices. + /// Re-registers as an + /// NSubstitute substitute whose BotClient property returns the + /// supplied stub. + /// Sets + /// TelegramBotWebServiceOptions:WorkingMode to + /// + /// in the host's in-memory configuration so the host does NOT register + /// the PoolingProcessor / ConfigureWebhook hosted services + /// (both of which would resolve during host + /// startup, before our DI override has a chance to win). + /// Supplies a dummy non-empty BotToken so the + /// BotOptions validation chain (which runs lazily on first + /// IOptions<TelegramBotWebServiceOptions>.Value access) does + /// not blow up. + /// + /// + /// The bot client substitute is exposed as the protected + /// so derived scenarios can assert on + /// outgoing bot calls (e.g. + /// BotClientStub.Received().SendRequest(...)) when relevant. + /// + /// + public abstract class TelegramWebhookScenarioBaseTest : WebApiBaseTest + { + /// + /// Gets the NSubstitute substitute + /// the host's exposes via its + /// BotClient property. Derived scenarios can assert on + /// BotClientStub.Received().SendRequest(...) if they need to + /// verify outgoing bot calls. + /// + protected ITelegramBotClient BotClientStub { get; } + + /// + /// Initializes a new instance of the + /// class with default + /// (bare, no-setup) bot-client substitute and no extra configuration + /// overrides. + /// + protected TelegramWebhookScenarioBaseTest() + : this(botClientStub: null, extraConfiguration: null) + { + } + + /// + /// Initializes a new instance of the + /// class. + /// + /// + /// Optional pre-built substitute. + /// When null, a bare + /// stub + /// is used. + /// + /// + /// Optional in-memory configuration overrides layered on top of the + /// host's appsettings.json. The base class always layers a + /// WorkingMode=WebhookViaServiceSetup override (so the + /// polling / webhook hosted services do not start) plus a dummy + /// BotToken override. + /// + protected TelegramWebhookScenarioBaseTest( + ITelegramBotClient? botClientStub, + IReadOnlyDictionary? extraConfiguration) + : this( + resolvedBotClientStub: new ResolvedStub( + botClientStub ?? TestTelegramBotClientBuilder.CreateWithoutSetup(BaseMockTest.CreateFixture())), + extraConfiguration: extraConfiguration) + { + } + + // The private ctor takes a wrapper type so the overload resolution + // is unambiguous and the bot-client stub is captured once + reused + // both for the BotService substitute (passed through the + // ConfigureTestServices delegate) and as the protected + // BotClientStub property exposed to derived scenarios. + private TelegramWebhookScenarioBaseTest( + ResolvedStub resolvedBotClientStub, + IReadOnlyDictionary? extraConfiguration) + : base( + jwtConfig: null, + extraConfiguration: BuildConfiguration(extraConfiguration), + configureTestServices: services => ConfigureBotServiceSwap(services, resolvedBotClientStub.Client)) + { + BotClientStub = resolvedBotClientStub.Client; + } + + // Tiny holder so the private ctor's signature does NOT collide with + // the protected overload (which also accepts an ITelegramBotClient? + // + extra-configuration pair). + private readonly record struct ResolvedStub(ITelegramBotClient Client); + + /// + public override Task InitializeAsync() + { + CapturedException.EnsureNLogMemoryTarget(); + CapturedException.Clear(); + return base.InitializeAsync(); + } + + private static void ConfigureBotServiceSwap( + IServiceCollection services, + ITelegramBotClient botClientStub) + { + services.RemoveAll(); + services.AddSingleton(new StubBotService(botClientStub)); + + // The production CommunicationServiceClient's ctor instantiates + // an HttpClient and validates RestApi/UserService options chain + // — its inputs are not stable enough to construct during a + // webhook integration test. Replace it with a no-setup + // NSubstitute stub so any handler that resolves the client + // does not blow up. Webhook scenarios do not assert on the + // outgoing comm-client calls; polling scenarios will + // pass a configured stub via the same factory knob. + services.RemoveAll(); + services.AddSingleton(TestCommunicationServiceClientBuilder.CreateWithoutSetup(BaseMockTest.CreateFixture())); + } + + private static IReadOnlyDictionary BuildConfiguration( + IReadOnlyDictionary? extra) + { + var merged = new Dictionary + { + // Force the host into a working mode that does NOT register + // a hosted service that resolves IBotService at startup — + // the swap in ConfigureTestServices fires after Startup + // runs, so any service resolution before that point would + // pull in the production BotService and explode on the + // empty BotToken. + ["TelegramBotWebServiceOptions:WorkingMode"] = + nameof(TelegramBotWebServiceWorkingMode.WebhookViaServiceSetup), + + // Supply a non-empty dummy bot token so the BotOptions + // validation chain doesn't blow up. The token is never + // used because IBotService is replaced. + ["TelegramBotWebServiceOptions:Bot:Token"] = "test-only-dummy-bot-token", + }; + + if (extra is not null) + { + foreach (var kvp in extra) + { + merged[kvp.Key] = kvp.Value; + } + } + + return merged; + } + } +} diff --git a/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/TelegramWebhookTextMessageTests.cs b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/TelegramWebhookTextMessageTests.cs new file mode 100644 index 00000000..1dd355d3 --- /dev/null +++ b/Sources/Tests/ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/TelegramWebhookTextMessageTests.cs @@ -0,0 +1,82 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using AwesomeAssertions; +using Newtonsoft.Json; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Xunit; + +namespace ProjectV.TelegramBotWebService.Tests.Scenarios.Webhook +{ + /// + /// Scenario TG-WEB-1: webhook receives a /start command and the + /// production controller returns HTTP 200. + /// + /// + /// The test posts a synthetic Telegram.Bot.Types.Update JSON + /// payload to the production + /// POST /api/v1/Update endpoint (defined by + /// ). The host's + /// IBotService is replaced by an NSubstitute substitute so that + /// the bot handler chain ( → + /// → + /// SendMessageAsync) runs end-to-end against the real ASP.NET Core + /// pipeline without contacting the live Telegram API. The scenario + /// asserts only that the controller responds 200 — that single status + /// proves the entire model-binding + auth + middleware + handler chain + /// is healthy on the webhook path. + /// + [Trait("Category", "Integration")] + public sealed class TelegramWebhookTextMessageTests : TelegramWebhookScenarioBaseTest + { + /// + /// Initializes a new instance of the + /// class. + /// + public TelegramWebhookTextMessageTests() + { + } + + /// + /// Scenario TG-WEB-1 — POST a valid Update with a /start + /// text message returns HTTP 200. + /// + [Fact] + public async Task PostUpdate_WithValidTextMessage_Returns200() + { + // Arrange. + var update = new Update + { + Id = 1, + Message = new Message + { + Id = 100, + Text = "/start", + Chat = new Chat + { + Id = 999L, + Type = ChatType.Private + }, + From = new User + { + Id = 999L, + FirstName = "Test", + IsBot = false + } + } + }; + string body = JsonConvert.SerializeObject(update); + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + + // Act. + using HttpResponseMessage response = await Client.PostAsync( + "/api/v1/Update", content); + + // Assert. + response.StatusCode.Should().Be(HttpStatusCode.OK, + "the webhook handler chain processed the /start command without faulting"); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/ForTests/BaseDependencyInjectionTests.cs b/Sources/Tests/ProjectV.Tests.Shared/ForTests/BaseDependencyInjectionTests.cs new file mode 100644 index 00000000..5fe8c8cb --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/ForTests/BaseDependencyInjectionTests.cs @@ -0,0 +1,88 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ProjectV.Tests.Shared.ForTests +{ + /// + /// Base class for tests that exercise dependency-injection container + /// registration (e.g. AddProjectVCore()-style extension methods). + /// Provides factory helpers for an empty service collection and a host + /// application builder, plus AwesomeAssertions-based assertions for + /// service presence and implementation type. + /// + public abstract class BaseDependencyInjectionTests : BaseMockTest + { + /// + /// Initializes a new instance of the + /// class. + /// + protected BaseDependencyInjectionTests() + { + } + + /// + /// Creates an empty for testing + /// container registration extensions. + /// + protected static IServiceCollection CreateServiceCollection() + { + return new ServiceCollection(); + } + + /// + /// Creates an via + /// for tests that + /// register options or hosted services. + /// + protected static IHostApplicationBuilder CreateHostAppBuilder() + { + return Host.CreateApplicationBuilder(); + } + + /// + /// Asserts that the specified service type is NOT registered in the + /// supplied collection. + /// + /// Service type to check. + /// Service collection under test. + protected static void AssertOn_NotRegistered(IServiceCollection services) + { + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(T)); + descriptor.Should().BeNull( + $"service {typeof(T).Name} should NOT be registered."); + } + + /// + /// Asserts that the specified service type is registered (at least + /// once) in the supplied collection. + /// + /// Service type to check. + /// Service collection under test. + protected static void AssertOn_RegisteredService(IServiceCollection services) + { + var descriptors = services + .Where(d => d.ServiceType == typeof(T)) + .ToList(); + descriptors.Should().NotBeEmpty( + $"service {typeof(T).Name} should be registered."); + } + + /// + /// Asserts that the specified service type is registered with the + /// requested implementation type (or instance type). + /// + /// Service type to look up. + /// Expected implementation type. + /// Service collection under test. + protected static void AssertOn_RegisteredServiceBeOfType( + IServiceCollection services) + { + var descriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(TService) + && (d.ImplementationType == typeof(TExpected) + || d.ImplementationInstance?.GetType() == typeof(TExpected))); + descriptor.Should().NotBeNull( + $"service {typeof(TService).Name} should be registered as {typeof(TExpected).Name}."); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/ForTests/BaseExceptionTests.cs b/Sources/Tests/ProjectV.Tests.Shared/ForTests/BaseExceptionTests.cs new file mode 100644 index 00000000..1bbc6f4f --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/ForTests/BaseExceptionTests.cs @@ -0,0 +1,84 @@ +namespace ProjectV.Tests.Shared.ForTests +{ + /// + /// Base class for tests that verify the standard 3-constructor convention + /// every custom exception in ProjectV follows + /// ((), (string message), + /// (string message, Exception innerException) — see CONVENTIONS.md). + /// Concrete test classes implement the / + /// / + /// factory hooks for their + /// specific type. + /// + /// Exception type under test. + public abstract class BaseExceptionTests : BaseMockTest + where TException : Exception + { + /// + /// Initializes a new instance of the + /// class. + /// + protected BaseExceptionTests() + { + } + + /// + /// Creates a via the default + /// (parameterless) constructor. + /// + protected abstract TException Create(); + + /// + /// Creates a via the + /// message-only constructor. + /// + /// Exception message. + protected abstract TException Create(string message); + + /// + /// Creates a via the + /// message + inner-exception constructor. + /// + /// Exception message. + /// Wrapped exception. + protected abstract TException Create(string message, Exception innerException); + + [Fact] + public void DefaultConstructor_CreatesException() + { + // Arrange. / Act. + var ex = Create(); + + // Assert. + ex.Should().NotBeNull(); + } + + [Fact] + public void MessageConstructor_SetsMessage() + { + // Arrange. + const string message = "Test exception message."; + + // Act. + var ex = Create(message); + + // Assert. + ex.Message.Should().Be(message); + } + + [Fact] + public void InnerExceptionConstructor_SetsMessageAndInnerException() + { + // Arrange. + const string message = "Test exception message."; + var inner = new InvalidOperationException("inner"); + + // Act. + var ex = Create(message, inner); + + // Assert. + ex.Message.Should().Be(message); + ex.InnerException.Should().BeSameAs(inner); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/ForTests/BaseMockTest.cs b/Sources/Tests/ProjectV.Tests.Shared/ForTests/BaseMockTest.cs new file mode 100644 index 00000000..22e19ec2 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/ForTests/BaseMockTest.cs @@ -0,0 +1,55 @@ +using AutoFixture; +using AutoFixture.AutoNSubstitute; + +namespace ProjectV.Tests.Shared.ForTests +{ + /// + /// Base class for unit tests that need substitutes. Exposes an + /// wired with the + /// so that + /// Fixture.Freeze<T>() and Fixture.Create<T>() + /// return proxies for any interface or + /// virtual class without per-test boilerplate. + /// + /// + /// New tests should prefer over hand-rolling + /// Substitute.For<T>() calls. Test-class helpers should + /// also use rather than reaching for + /// Substitute.For directly. + /// + public abstract class BaseMockTest : BaseTest + { + /// + /// Per-test instance. A fresh fixture is + /// created for every test class instance (xUnit constructs a new + /// instance per test method), so frozen substitutes do not leak + /// across tests. + /// + protected IFixture Fixture { get; } + + /// + /// Initializes a new instance of the + /// class with a fresh . + /// + protected BaseMockTest() + { + Fixture = CreateFixture(); + } + + /// + /// Factory method that builds a new with the + /// applied. Exposed as a + /// static so test helpers that need a fixture without inheriting + /// from can still get one configured the + /// same way. + /// + /// + /// A new with NSubstitute-backed automatic + /// substitution. + /// + public static IFixture CreateFixture() + { + return new Fixture().Customize(new AutoNSubstituteCustomization()); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/ForTests/BaseTest.cs b/Sources/Tests/ProjectV.Tests.Shared/ForTests/BaseTest.cs new file mode 100644 index 00000000..9213f05d --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/ForTests/BaseTest.cs @@ -0,0 +1,18 @@ +namespace ProjectV.Tests.Shared.ForTests +{ + /// + /// Base class for all ProjectV tests. xUnit treats every test class as its + /// own fixture; no [TestFixture] attribute is needed. The constructor + /// replaces NUnit's [SetUp] and or + /// replaces [TearDown]. + /// + public abstract class BaseTest + { + /// + /// Initializes a new instance of the class. + /// + protected BaseTest() + { + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/ForTests/TestDbHelper.cs b/Sources/Tests/ProjectV.Tests.Shared/ForTests/TestDbHelper.cs new file mode 100644 index 00000000..39545b9c --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/ForTests/TestDbHelper.cs @@ -0,0 +1,67 @@ +using Acolyte.Assertions; +using Npgsql; + +namespace ProjectV.Tests.Shared.ForTests +{ + /// + /// Utility for Testcontainers-based DB reset between test cases. Issues a + /// TRUNCATE … RESTART IDENTITY CASCADE against the three production + /// DAL tables to wipe row state without dropping the schema. + /// Call from + /// of each integration test class so every test starts on a clean slate. + /// + /// + /// + /// Table names "public"."jobs" / "users" / "tokens" match the + /// [Table(...)] attributes on + /// ProjectV.DataAccessLayer.Services.{Jobs,Users,Tokens}.Models.*DbInfo + /// and the HasDefaultSchema("public") declaration in + /// ProjectVDbContext.OnModelCreating. Double-quoted identifiers + /// preserve PostgreSQL case sensitivity. + /// + /// + /// Takes a raw connection string rather than a ProjectVDbContext + /// because the production context's OnModelCreating raises a + /// on the + /// UserDbInfo.RefreshToken property whenever the dependency cache + /// is first realised — even for a TRUNCATE that never touches the model. + /// See DbCollectionFixture remarks for the full rationale. + /// Using directly keeps the helper + /// independent of EF Core's model validator. + /// + /// + public sealed class TestDbHelper + { + private readonly string _connectionString; + + + /// + /// Initializes a new instance of the class. + /// + /// + /// PostgreSQL connection string of the test container. Must point at + /// the same database the SUT's ProjectVDbContext consumes. + /// + public TestDbHelper(string connectionString) + { + _connectionString = connectionString.ThrowIfNullOrWhiteSpace( + nameof(connectionString)); + } + + /// + /// Resets all DAL test tables (jobs, users, tokens) + /// to empty state, preserving schema and resetting any identity + /// sequences. Use from + /// before constructing the system under test. + /// + public async Task TruncateAllTablesAsync() + { + await using var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand( + "TRUNCATE TABLE \"public\".\"jobs\", \"public\".\"users\", \"public\".\"tokens\" RESTART IDENTITY CASCADE;", + connection); + await command.ExecuteNonQueryAsync(); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/ForTests/TestModuleInitializer.cs b/Sources/Tests/ProjectV.Tests.Shared/ForTests/TestModuleInitializer.cs new file mode 100644 index 00000000..c66ebdd2 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/ForTests/TestModuleInitializer.cs @@ -0,0 +1,71 @@ +using System.Runtime.CompilerServices; +using NLog.Config; + +namespace ProjectV.Tests.Shared.ForTests +{ + /// + /// Module initializer for the ProjectV.Tests.Shared assembly. + /// Pre-installs an empty NLog so that + /// production types with a static NLog.Logger field do not write + /// log files to ${CommonApplicationData}/ProjectV/logs/ during a + /// test run. + /// + /// + /// + /// History: each of the 12 downstream test assemblies used to declare its + /// own private [ModuleInitializer] with this same body. The + /// duplication was a workaround for the NLog 6 auto-load failure caused + /// by concurrentWrites="true" in + /// Sources/Libraries/ProjectV.Logging/NLog.config, combined with + /// throwConfigExceptions="true". Removing that attribute stopped + /// the auto-load from throwing and made the workaround no longer + /// load-bearing for build/test correctness. + /// + /// + /// This single hoisted initializer remains for a softer reason: tests + /// should not litter the host's production log directory with stray + /// entries. Tests.Shared is referenced (and globally-used) by every C# + /// test assembly, so its module initializer fires when downstream test + /// code first touches a Tests.Shared symbol. Because the + /// global using directive in Usings/SharedUsings.cs is + /// compile-time only (the C# compiler resolves it without forcing the + /// referenced assembly to load), there is a race window between process + /// start and the first Tests.Shared symbol use. During that window any + /// production type with a static NLog.Logger field + /// (CrawlersManager, OutputManager, etc.) can write a + /// handful of early log lines into ${CommonApplicationData}/ProjectV/logs/ + /// before the empty takes over. + /// + /// + /// This trade-off is accepted intentionally: with the + /// concurrentWrites="true" attribute removed from NLog.config + /// the NLog 6 auto-load no longer throws on that specific cause, so the + /// worst-case outcome is a few stray log lines per test process rather + /// than a load-bearing test correctness risk. Other NLog auto-load + /// failure modes still exist (throwConfigExceptions="true" + /// combined with the <extensions> directive will throw if + /// ProjectV.Logging.dll is absent from the output directory, or + /// if NLog.config is malformed) — this initializer only guards + /// against log-file write side effects, not those other failures. + /// + /// + /// Do NOT re-add concurrentWrites="true" to + /// Sources/Libraries/ProjectV.Logging/NLog.config: NLog 6 dropped + /// that attribute and any future "I/O optimisation" pass that puts it + /// back will invalidate the rationale above and reintroduce the + /// per-assembly auto-load throw the consolidation was built to remove. + /// If stray production log writes during tests ever become unacceptable + /// (e.g. CI sandbox isolation), reinstate per-assembly + /// [ModuleInitializer]s in each test assembly so the empty config + /// is installed before any production static logger initializes. + /// + /// + internal static class TestModuleInitializer + { + [ModuleInitializer] + public static void Initialize() + { + NLog.LogManager.Configuration = new LoggingConfiguration(); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/ForTests/TestTimeHelper.cs b/Sources/Tests/ProjectV.Tests.Shared/ForTests/TestTimeHelper.cs new file mode 100644 index 00000000..993ea7d0 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/ForTests/TestTimeHelper.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Time.Testing; + +namespace ProjectV.Tests.Shared.ForTests +{ + /// + /// Thin wrapper around for tests that + /// need to control "now" and advance a virtual clock. Bridges the + /// project's preferred abstraction with the + /// xUnit test harness, giving tests deterministic control over time. + /// + public static class TestTimeHelper + { + /// + /// Creates a initialized at the + /// supplied instant. + /// + /// Initial value for "now". + /// A fresh . + public static FakeTimeProvider Create(DateTimeOffset initialNow) + { + return new FakeTimeProvider(initialNow); + } + + /// + /// Advances the supplied by the + /// requested . Pure convenience wrapper + /// around so test + /// bodies stay readable. + /// + /// Fake time provider to advance. + /// Amount of time to advance by. + public static void Advance(FakeTimeProvider timeProvider, TimeSpan delta) + { + ArgumentNullException.ThrowIfNull(timeProvider); + + timeProvider.Advance(delta); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/ForTests/WebApiBaseTest.cs b/Sources/Tests/ProjectV.Tests.Shared/ForTests/WebApiBaseTest.cs new file mode 100644 index 00000000..1efe1bca --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/ForTests/WebApiBaseTest.cs @@ -0,0 +1,175 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using Microsoft.Extensions.DependencyInjection; +using ProjectV.Tests.Shared.Helpers.WebApi; + +namespace ProjectV.Tests.Shared.ForTests +{ + /// + /// Base class for WebApi scenario tests that need an in-process host with + /// JWT authentication wired the same way production wires it. Owns the + /// instance, exposes a + /// default for anonymous calls, and provides + /// for calls that need a bearer + /// token. + /// + /// + /// + /// The factory is built lazily inside so + /// per-scenario base classes can hand a configured + /// , extra in-memory configuration overrides, + /// or a DI override action to the base ctor and have the host pick them + /// up. xUnit calls before the first test + /// runs and after the last one — that is the + /// public contract. + /// + /// + /// This base class deliberately does NOT depend on DbCollectionFixture; + /// the JWT path in CommunicationWebService uses an in-memory user + /// store. The Telegram webhook / polling families that DO need + /// Testcontainers Postgres add the [Collection(DbCollection.Name)] + /// attribute on their concrete subclass and pass the fixture through a + /// derived base class — they do not extend this one. + /// + /// + /// + /// The production Startup class type that the test host wraps. + /// + public abstract class WebApiBaseTest : BaseMockTest, IAsyncLifetime + where TStartup : class + { + private readonly IReadOnlyDictionary _extraConfiguration; + private readonly Action _configureTestServices; + + private TestWebApplicationFactory? _factory; + private HttpClient? _client; + + /// + /// Gets the lazily-initialised . + /// Available after has run. + /// + protected TestWebApplicationFactory Factory => + _factory ?? throw new InvalidOperationException( + "TestWebApplicationFactory is not initialised yet — InitializeAsync must run first." + ); + + /// + /// Gets a shared built from + /// without any Authorization header. Use it for anonymous-call + /// scenarios. + /// + protected HttpClient Client => + _client ?? throw new InvalidOperationException( + "HttpClient is not initialised yet — InitializeAsync must run first." + ); + + /// + /// Gets the JWT signing material the factory was built with — exposed + /// so a derived test can mint a token by hand if it needs claim + /// customisation beyond . + /// + protected TestJwtConfig JwtConfig { get; } + + /// + /// Initializes a new instance of . + /// + /// + /// Optional JWT signing-material bundle. Defaults to + /// defaults (test-only base64 secret, + /// https://localhost issuer + audience). + /// + /// + /// Optional in-memory configuration overrides layered on top of the + /// host's appsettings.json / env vars. Useful for tweaking + /// UserServiceOptions or other Options on a per-scenario basis. + /// + /// + /// Optional DI override action; runs AFTER + /// Startup.ConfigureServices. Defaults to a no-op. + /// + protected WebApiBaseTest( + TestJwtConfig? jwtConfig = null, + IReadOnlyDictionary? extraConfiguration = null, + Action? configureTestServices = null) + { + JwtConfig = jwtConfig ?? new TestJwtConfig(); + _extraConfiguration = extraConfiguration ?? new Dictionary(); + _configureTestServices = configureTestServices ?? (_ => { }); + } + + /// + /// Builds the and + /// the shared anonymous . Called by xUnit before + /// the first test method. + /// + public virtual Task InitializeAsync() + { + _factory = new TestWebApplicationFactory + { + JwtConfig = JwtConfig, + ExtraConfigurationValues = _extraConfiguration, + ConfigureTestServices = _configureTestServices + }; + + _client = _factory.CreateClient(); + + return Task.CompletedTask; + } + + /// + /// Disposes the factory and the shared client. Called by xUnit after + /// the last test method runs. + /// + public virtual async Task DisposeAsync() + { + _client?.Dispose(); + _client = null; + + if (_factory is not null) + { + await _factory.DisposeAsync(); + _factory = null; + } + } + + /// + /// Builds a fresh with an + /// Authorization: Bearer <token> header. The token is + /// signed with the same secret / issuer / audience the host was + /// configured with, so the production + /// TokenValidationParameters accept it. + /// + /// + /// Optional user-id claim value (mapped to + /// ClaimTypes.NameIdentifier). Mirrors the production + /// TokenGenerator claim layout. + /// + /// + /// Optional user-name claim value (mapped to ClaimTypes.Name). + /// + /// + /// Optional token lifetime; defaults to five minutes. + /// + /// + /// A new instance with the bearer token + /// attached. The caller is responsible for disposing it. + /// + protected HttpClient CreateAuthenticatedClient( + string? userId = null, + string? userName = null, + TimeSpan? expiry = null) + { + var token = TestJwtHelper.GenerateTestBearerToken( + config: JwtConfig, + userId: userId, + userName: userName, + expiry: expiry + ); + + var client = Factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token); + return client; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Fixtures/FixtureLoader.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Fixtures/FixtureLoader.cs new file mode 100644 index 00000000..a9fc47ff --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Fixtures/FixtureLoader.cs @@ -0,0 +1,53 @@ +using System.IO; +using Acolyte.Assertions; + +namespace ProjectV.Tests.Shared.Helpers.Fixtures +{ + /// + /// Loads recorded JSON fixture files from + /// Sources/Tests/Fixtures/. Used by contract tests + /// (TMDb/OMDb/Steam) and any other suite that prefers static fixtures + /// over in-memory mocks. + /// + /// + /// Fixture files are copied to the test output directory at build time. + /// At runtime the loader resolves them against + /// + Fixtures. + /// Naming convention: {Provider}/{endpoint}-{scenario}.json + /// (for example Tmdb/movie-by-id-success.json). + /// + public static class FixtureLoader + { + private static readonly string _fixturesRoot = + Path.Combine(AppContext.BaseDirectory, "Fixtures"); + + /// + /// Reads and returns the raw JSON content of a recorded fixture file. + /// + /// + /// Path relative to Sources/Tests/Fixtures/, for example + /// Tmdb/movie-by-id-success.json. + /// + /// The fixture file contents as a string. + /// + /// Thrown when is + /// null, empty, or whitespace. + /// + /// + /// Thrown when the resolved fixture file does not exist on disk. + /// + public static string LoadJsonFixture(string relativeFixturePath) + { + relativeFixturePath.ThrowIfNullOrWhiteSpace(nameof(relativeFixturePath)); + + string fullPath = Path.Combine(_fixturesRoot, relativeFixturePath); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException( + $"Fixture file not found: '{fullPath}'.", fullPath); + } + + return File.ReadAllText(fullPath); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/DataAccessLayer/JobInfoGenerator.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/DataAccessLayer/JobInfoGenerator.cs new file mode 100644 index 00000000..ae62c75c --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/DataAccessLayer/JobInfoGenerator.cs @@ -0,0 +1,144 @@ +using Acolyte.Assertions; +using ProjectV.Models.Internal.Jobs; +using ProjectV.Tests.Shared.Helpers.Generators.Models; + +namespace ProjectV.Tests.Shared.Helpers.Generators.DataAccessLayer +{ + /// + /// Generator for test data. Follows the + /// Create(...) / Generate(...) twin pattern: + /// + /// + /// Create* — every argument is explicit; the + /// caller is responsible for the resulting + /// being valid. + /// + /// + /// Generate* — every argument is optional; + /// unspecified values come from a deterministic seeded + /// (seed 42 for deterministic runs). + /// + /// + /// + public sealed class JobInfoGenerator + { + private static readonly Random _random = new Random(Seed: 42); + + private readonly JobIdGenerator _jobIdGenerator; + + + /// + /// Initializes a new instance of the + /// class with a default . + /// + public JobInfoGenerator() + : this(new JobIdGenerator()) + { + } + + /// + /// Initializes a new instance of the + /// class with a caller-supplied — useful + /// when a test needs a specific series. + /// + /// Generator for the id field. + public JobInfoGenerator(JobIdGenerator jobIdGenerator) + { + _jobIdGenerator = jobIdGenerator.ThrowIfNull(nameof(jobIdGenerator)); + } + + /// + /// Creates a with every field supplied + /// explicitly by the caller. + /// + /// Job identifier — must be specified (non-default). + /// Job name — must not be null or whitespace. + /// Job state code. + /// Job result code. + /// Job configuration payload — must not be null or whitespace. + /// A new instance. + public JobInfo CreateJobInfo( + JobId id, string name, int state, int result, string config) + { + name.ThrowIfNullOrWhiteSpace(nameof(name)); + config.ThrowIfNullOrWhiteSpace(nameof(config)); + + return new JobInfo( + id: id, + name: name, + state: state, + result: result, + config: config + ); + } + + /// + /// Generates a filling any unspecified field + /// with a deterministic value derived from the seeded random source. + /// + /// Optional job identifier. + /// Optional job name. + /// Optional state code. + /// Optional result code. + /// Optional configuration payload. + /// A new instance. + public JobInfo GenerateJobInfo( + JobId? id = null, + string? name = null, + int? state = null, + int? result = null, + string? config = null) + { + return CreateJobInfo( + id: id ?? GenerateId(), + name: name ?? GenerateName(), + state: state ?? GenerateState(), + result: result ?? GenerateResult(), + config: config ?? GenerateConfig() + ); + } + + /// + /// Generates a fresh via the underlying + /// . + /// + public JobId GenerateId() + { + return _jobIdGenerator.GenerateJobId(); + } + + /// + /// Generates a unique job name with a GUID-derived suffix. + /// + public string GenerateName() + { + return $"job-{Guid.NewGuid():N}"; + } + + /// + /// Generates a deterministic job state code in the range [0, 100). + /// + public int GenerateState() + { + return _random.Next(0, 100); + } + + /// + /// Generates a deterministic job result code in the range [0, 100). + /// + public int GenerateResult() + { + return _random.Next(0, 100); + } + + /// + /// Generates a deterministic non-empty configuration payload using a + /// GUID-derived suffix; ProjectV stores the raw XML/JSON config as a + /// string in the config column. + /// + public string GenerateConfig() + { + return $""; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/DataAccessLayer/RefreshTokenInfoGenerator.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/DataAccessLayer/RefreshTokenInfoGenerator.cs new file mode 100644 index 00000000..22692c6e --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/DataAccessLayer/RefreshTokenInfoGenerator.cs @@ -0,0 +1,149 @@ +using Acolyte.Assertions; +using ProjectV.Models.Authorization; +using ProjectV.Models.Authorization.Tokens; +using ProjectV.Models.Users; +using ProjectV.Tests.Shared.Helpers.Generators.Models; + +namespace ProjectV.Tests.Shared.Helpers.Generators.DataAccessLayer +{ + /// + /// Generator for test data. Follows the + /// Create(...) / Generate(...) twin pattern: + /// + /// + /// Create* — every argument is explicit. + /// + /// + /// Generate* — every argument is optional; + /// unspecified values come from deterministic helpers (seeded + /// seed 42 for deterministic runs + GUIDs). + /// + /// + /// + public sealed class RefreshTokenInfoGenerator + { + private static readonly Random _random = new Random(Seed: 42); + + private readonly UserIdGenerator _userIdGenerator; + + + /// + /// Initializes a new instance of the + /// class with a default + /// . + /// + public RefreshTokenInfoGenerator() + : this(new UserIdGenerator()) + { + } + + /// + /// Initializes a new instance of the + /// class with a caller-supplied + /// — useful when a test needs to link + /// the token to a specific user. + /// + /// Generator for the userId field. + public RefreshTokenInfoGenerator(UserIdGenerator userIdGenerator) + { + _userIdGenerator = userIdGenerator.ThrowIfNull(nameof(userIdGenerator)); + } + + /// + /// Creates a with every field supplied + /// explicitly by the caller. + /// + /// Token identifier — must be specified. + /// Owning user identifier — must be specified. + /// Token hash — must not be null/whitespace. + /// Token salt — must not be null. + /// Token creation timestamp (UTC). + /// Token expiry timestamp (UTC). + /// A new instance. + public RefreshTokenInfo CreateRefreshTokenInfo( + RefreshTokenId id, + UserId userId, + Password tokenHash, + string tokenSalt, + DateTime creationTimeUtc, + DateTime expiryDateUtc) + { + tokenSalt.ThrowIfNull(nameof(tokenSalt)); + + return new RefreshTokenInfo( + id: id, + userId: userId, + tokenHash: tokenHash, + tokenSalt: tokenSalt, + creationTimeUtc: creationTimeUtc, + expiryDateUtc: expiryDateUtc + ); + } + + /// + /// Generates a filling any unspecified + /// field with a deterministic value. + /// + /// Optional token identifier. + /// Optional owning user identifier. + /// Optional token hash. + /// Optional token salt. + /// Optional creation timestamp. + /// Optional expiry timestamp. + /// A new instance. + public RefreshTokenInfo GenerateRefreshTokenInfo( + RefreshTokenId? id = null, + UserId? userId = null, + Password? tokenHash = null, + string? tokenSalt = null, + DateTime? creationTimeUtc = null, + DateTime? expiryDateUtc = null) + { + DateTime creation = creationTimeUtc ?? GenerateCreationTimeUtc(); + return CreateRefreshTokenInfo( + id: id ?? GenerateId(), + userId: userId ?? _userIdGenerator.GenerateUserId(), + tokenHash: tokenHash ?? GenerateTokenHash(), + tokenSalt: tokenSalt ?? GenerateTokenSalt(), + creationTimeUtc: creation, + expiryDateUtc: expiryDateUtc ?? creation.AddDays(7) + ); + } + + /// + /// Generates a fresh from a new GUID. + /// + public RefreshTokenId GenerateId() + { + return RefreshTokenId.Wrap(Guid.NewGuid()); + } + + /// + /// Generates a deterministic for use as a + /// token hash, with a GUID-derived suffix. + /// + public Password GenerateTokenHash() + { + return Password.Wrap($"token-{Guid.NewGuid():N}"); + } + + /// + /// Generates a deterministic token salt with a GUID-derived suffix. + /// + public string GenerateTokenSalt() + { + return $"token-salt-{Guid.NewGuid():N}"; + } + + /// + /// Generates a deterministic UTC creation timestamp anchored at + /// 2020-01-01 + a seeded number of seconds. + /// + public DateTime GenerateCreationTimeUtc() + { + int offsetSeconds = _random.Next(0, 365 * 24 * 60 * 60); + return new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) + .AddSeconds(offsetSeconds); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/DataAccessLayer/UserInfoGenerator.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/DataAccessLayer/UserInfoGenerator.cs new file mode 100644 index 00000000..0fe505ee --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/DataAccessLayer/UserInfoGenerator.cs @@ -0,0 +1,164 @@ +using Acolyte.Assertions; +using ProjectV.Models.Authorization; +using ProjectV.Models.Authorization.Tokens; +using ProjectV.Models.Users; +using ProjectV.Tests.Shared.Helpers.Generators.Models; + +namespace ProjectV.Tests.Shared.Helpers.Generators.DataAccessLayer +{ + /// + /// Generator for test data. Follows the + /// Create(...) / Generate(...) twin pattern: + /// + /// + /// Create* — every argument is explicit; the + /// caller is responsible for the resulting + /// being valid. + /// + /// + /// Generate* — every argument is optional; + /// unspecified values come from deterministic helpers (seeded + /// seed 42 for deterministic runs + GUIDs). + /// + /// + /// + public sealed class UserInfoGenerator + { + private static readonly Random _random = new Random(Seed: 42); + + private readonly UserIdGenerator _userIdGenerator; + + + /// + /// Initializes a new instance of the + /// class with a default . + /// + public UserInfoGenerator() + : this(new UserIdGenerator()) + { + } + + /// + /// Initializes a new instance of the + /// class with a caller-supplied . + /// + /// Generator for the id field. + public UserInfoGenerator(UserIdGenerator userIdGenerator) + { + _userIdGenerator = userIdGenerator.ThrowIfNull(nameof(userIdGenerator)); + } + + /// + /// Creates a with every field supplied + /// explicitly by the caller. + /// + /// User identifier — must be specified. + /// User name — must not be null/whitespace. + /// Password value — must not be null/whitespace. + /// Password salt — must not be null/whitespace. + /// Creation timestamp (UTC). + /// Whether the account is active. + /// Optional refresh token — pass null for no token. + /// A new instance. + public UserInfo CreateUserInfo( + UserId id, + UserName userName, + Password password, + string passwordSalt, + DateTime creationTimeUtc, + bool active, + RefreshTokenInfo? refreshToken) + { + passwordSalt.ThrowIfNullOrWhiteSpace(nameof(passwordSalt)); + + return new UserInfo( + id: id, + userName: userName, + password: password, + passwordSalt: passwordSalt, + creationTimeUtc: creationTimeUtc, + active: active, + refreshToken: refreshToken + ); + } + + /// + /// Generates a filling any unspecified field + /// with a deterministic value. + /// + /// Optional user identifier. + /// Optional user name. + /// Optional password value. + /// Optional password salt. + /// Optional creation timestamp. + /// Optional active flag (defaults to true when omitted). + /// Optional refresh token — pass null (default) for no token. + /// A new instance. + public UserInfo GenerateUserInfo( + UserId? id = null, + UserName? userName = null, + Password? password = null, + string? passwordSalt = null, + DateTime? creationTimeUtc = null, + bool? active = null, + RefreshTokenInfo? refreshToken = null) + { + return CreateUserInfo( + id: id ?? GenerateId(), + userName: userName ?? GenerateUserName(), + password: password ?? GeneratePassword(), + passwordSalt: passwordSalt ?? GeneratePasswordSalt(), + creationTimeUtc: creationTimeUtc ?? GenerateCreationTimeUtc(), + active: active ?? true, + refreshToken: refreshToken + ); + } + + /// + /// Generates a fresh via the underlying + /// . + /// + public UserId GenerateId() + { + return _userIdGenerator.GenerateUserId(); + } + + /// + /// Generates a deterministic with a + /// GUID-derived suffix. + /// + public UserName GenerateUserName() + { + return UserName.Wrap($"user-{Guid.NewGuid():N}"); + } + + /// + /// Generates a deterministic with a + /// GUID-derived suffix. + /// + public Password GeneratePassword() + { + return Password.Wrap($"pwd-{Guid.NewGuid():N}"); + } + + /// + /// Generates a deterministic password salt with a GUID-derived suffix. + /// + public string GeneratePasswordSalt() + { + return $"salt-{Guid.NewGuid():N}"; + } + + /// + /// Generates a deterministic UTC creation timestamp. Anchored at the + /// epoch + a seeded number of seconds to keep value ranges stable + /// across test runs. + /// + public DateTime GenerateCreationTimeUtc() + { + int offsetSeconds = _random.Next(0, 365 * 24 * 60 * 60); + return new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) + .AddSeconds(offsetSeconds); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/Models/BasicInfoGenerator.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/Models/BasicInfoGenerator.cs new file mode 100644 index 00000000..cde332b3 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/Models/BasicInfoGenerator.cs @@ -0,0 +1,115 @@ +using Acolyte.Assertions; +using ProjectV.Models.Data; + +namespace ProjectV.Tests.Shared.Helpers.Generators.Models +{ + /// + /// Generator for test data. Follows the + /// Create(...) / Generate(...) twin pattern: + /// + /// + /// Create* — every argument is explicit; the + /// caller is responsible for the resulting + /// being valid. + /// + /// + /// Generate* — every argument is optional; + /// unspecified values come from a deterministic seeded + /// (seed 42 for deterministic runs). + /// + /// + /// + public sealed class BasicInfoGenerator + { + private static readonly Random _random = new Random(Seed: 42); + + /// + /// Initializes a new instance of the + /// class. + /// + public BasicInfoGenerator() + { + } + + /// + /// Creates a with every field supplied + /// explicitly by the caller. + /// + /// Unique identifier. + /// Title — must not be null. + /// Number of votes. + /// Average vote value. + /// A new instance. + public BasicInfo CreateBasicInfo( + int thingId, string title, int voteCount, double voteAverage) + { + title.ThrowIfNull(nameof(title)); + + return new BasicInfo( + thingId: thingId, + title: title, + voteCount: voteCount, + voteAverage: voteAverage + ); + } + + /// + /// Generates a filling any unspecified field + /// with a deterministic value derived from the seeded random source. + /// + /// Optional unique identifier. + /// Optional title. + /// Optional vote count. + /// Optional vote average. + /// A new instance. + public BasicInfo GenerateBasicInfo( + int? thingId = null, + string? title = null, + int? voteCount = null, + double? voteAverage = null) + { + return CreateBasicInfo( + thingId: thingId ?? GenerateThingId(), + title: title ?? GenerateTitle(), + voteCount: voteCount ?? GenerateVoteCount(), + voteAverage: voteAverage ?? GenerateVoteAverage() + ); + } + + /// + /// Generates a deterministic in the + /// range [1, 1_000_000). + /// + public int GenerateThingId() + { + return _random.Next(1, 1_000_000); + } + + /// + /// Generates a unique title using a random GUID suffix. Not seeded + /// (GUIDs are global) — use when an + /// exact title is needed. + /// + public string GenerateTitle() + { + return $"Title-{Guid.NewGuid():N}"; + } + + /// + /// Generates a deterministic vote count in the range [10, 10_000). + /// + public int GenerateVoteCount() + { + return _random.Next(10, 10_000); + } + + /// + /// Generates a deterministic vote average in the range [0.0, 10.0] + /// rounded to one decimal place. + /// + public double GenerateVoteAverage() + { + return Math.Round(_random.NextDouble() * 10.0, 1); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/Models/JobIdGenerator.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/Models/JobIdGenerator.cs new file mode 100644 index 00000000..98232241 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/Models/JobIdGenerator.cs @@ -0,0 +1,71 @@ +using Acolyte.Assertions; +using ProjectV.Models.Internal.Jobs; + +namespace ProjectV.Tests.Shared.Helpers.Generators.Models +{ + /// + /// Generator for test data. Follows the + /// Create(...) / Generate(...) twin pattern: + /// + /// + /// Create* — every argument is explicit; the + /// caller is responsible for the resulting + /// being valid. + /// + /// + /// Generate* — every argument is optional; + /// unspecified values are filled with a freshly-generated + /// string (Guids are globally unique — no + /// seeded needed for this value + /// object). + /// + /// + /// + public sealed class JobIdGenerator + { + /// + /// Initializes a new instance of the + /// class. + /// + public JobIdGenerator() + { + } + + /// + /// Creates a from an explicit raw GUID string + /// via . + /// + /// + /// Raw GUID string. Must be a parseable, non-empty GUID; empty GUIDs + /// are rejected by . + /// + /// A new instance. + public JobId CreateJobId(string rawId) + { + rawId.ThrowIfNullOrEmpty(nameof(rawId)); + + return JobId.Parse(rawId); + } + + /// + /// Generates a , optionally seeded with a + /// caller-supplied raw GUID string. When is + /// null, a fresh is used. + /// + /// Optional raw GUID string. + /// A new instance. + public JobId GenerateJobId(string? rawId = null) + { + return CreateJobId(rawId ?? GenerateRawId()); + } + + /// + /// Generates a fresh raw GUID string (no dashes) suitable for feeding + /// into . + /// + public string GenerateRawId() + { + return Guid.NewGuid().ToString("N"); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/Models/UserIdGenerator.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/Models/UserIdGenerator.cs new file mode 100644 index 00000000..87830aa4 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Generators/Models/UserIdGenerator.cs @@ -0,0 +1,71 @@ +using Acolyte.Assertions; +using ProjectV.Models.Users; + +namespace ProjectV.Tests.Shared.Helpers.Generators.Models +{ + /// + /// Generator for test data. Follows the + /// Create(...) / Generate(...) twin pattern: + /// + /// + /// Create* — every argument is explicit; the + /// caller is responsible for the resulting + /// being valid. + /// + /// + /// Generate* — every argument is optional; + /// unspecified values are filled with a freshly-generated + /// string (Guids are globally unique — no + /// seeded needed for this value + /// object). + /// + /// + /// + public sealed class UserIdGenerator + { + /// + /// Initializes a new instance of the + /// class. + /// + public UserIdGenerator() + { + } + + /// + /// Creates a from an explicit raw GUID string + /// via . + /// + /// + /// Raw GUID string. Must be a parseable, non-empty GUID; empty GUIDs + /// are rejected by . + /// + /// A new instance. + public UserId CreateUserId(string rawId) + { + rawId.ThrowIfNullOrEmpty(nameof(rawId)); + + return UserId.Parse(rawId); + } + + /// + /// Generates a , optionally seeded with a + /// caller-supplied raw GUID string. When is + /// null, a fresh is used. + /// + /// Optional raw GUID string. + /// A new instance. + public UserId GenerateUserId(string? rawId = null) + { + return CreateUserId(rawId ?? GenerateRawId()); + } + + /// + /// Generates a fresh raw GUID string (no dashes) suitable for feeding + /// into . + /// + public string GenerateRawId() + { + return Guid.NewGuid().ToString("N"); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Http/FakeHttpMessageHandler.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Http/FakeHttpMessageHandler.cs new file mode 100644 index 00000000..0a449fe3 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Http/FakeHttpMessageHandler.cs @@ -0,0 +1,73 @@ +using System.Net.Http; +using System.Threading; + +namespace ProjectV.Tests.Shared.Helpers.Http +{ + /// + /// Test-only that returns a deterministic + /// for every call, driven by a + /// caller-supplied responder closure. Exposes a public read-only + /// counter so tests can assert on invocation + /// shape (e.g. retry counts, single-call expectations). + /// + /// + /// + /// Hoisted to ProjectV.Tests.Shared to eliminate duplication: + /// two private nested types with identical bodies previously existed + /// inside ProjectV.Core.Tests.Net.CommunicationServiceClientTests + /// and ProjectV.Core.Tests.Net.HttpClientPollyPolicyTests. + /// + /// + /// We do NOT mock via NSubstitute + /// because NSubstitute cannot intercept protected SendAsync. + /// A real subclass that returns + /// canned responses is the supported pattern. + /// + /// + /// The handler does NOT call base.SendAsync; it answers from the + /// responder closure directly. That makes it safe to use either as a + /// primary handler (no inner handler) or wrapped — Polly's retry policy + /// drives multiple invocations through the same primary instance, which + /// is why exists. + /// + /// + public sealed class FakeHttpMessageHandler : DelegatingHandler + { + private readonly Func _responder; + + /// + /// Number of times has been invoked on this + /// instance. Read-only externally; tests assert on this value to + /// verify retry counts and single-call expectations. + /// + public int CallCount { get; private set; } + + /// + /// Initializes a new instance of the + /// class. + /// + /// + /// Callback that turns each into an + /// . The handler attaches the + /// inbound request to the produced response via + /// RequestMessage before returning. + /// + /// + /// Thrown when is . + /// + public FakeHttpMessageHandler(Func responder) + { + _responder = responder ?? throw new ArgumentNullException(nameof(responder)); + } + + /// + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + HttpResponseMessage response = _responder(request); + response.RequestMessage = request; + return Task.FromResult(response); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Appraisers/TestAppraiserBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Appraisers/TestAppraiserBuilder.cs new file mode 100644 index 00000000..f80cdfda --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Appraisers/TestAppraiserBuilder.cs @@ -0,0 +1,133 @@ +using Acolyte.Assertions; +using AutoFixture; +using ProjectV.Appraisers; +using ProjectV.Models.Data; +using ProjectV.Models.Internal; + +namespace ProjectV.Tests.Shared.Helpers.Mocks.Appraisers +{ + /// + /// Builder for test doubles backed by + /// AutoFixture + NSubstitute. One file per interface; + /// downstream test plans add sibling builders following the same shape. + /// + public sealed class TestAppraiserBuilder + { + private readonly IFixture _fixture; + + private Type? _typeId; + private string? _tag; + private Func? _getRatingsHandler; + + /// + /// Initializes a new instance of the + /// class. No behavior is configured until one of the With* + /// methods is called. + /// + /// AutoFixture instance to create the substitute. + public TestAppraiserBuilder(IFixture fixture) + { + _fixture = fixture.ThrowIfNull(nameof(fixture)); + } + + /// + /// Convenience factory that returns a bare-bones + /// substitute with no configured behavior. + /// + /// AutoFixture instance to create the substitute. + public static IAppraiser CreateWithoutSetup(IFixture fixture) + { + fixture.ThrowIfNull(nameof(fixture)); + return new TestAppraiserBuilder(fixture).Build(); + } + + /// + /// Overrides the value returned by the + /// substitute. + /// + /// Type id. Must not be null. + /// This builder, for fluent chaining. + public TestAppraiserBuilder WithTypeId(Type typeId) + { + typeId.ThrowIfNull(nameof(typeId)); + + _typeId = typeId; + return this; + } + + /// + /// Overrides the value returned by the + /// substitute. + /// + /// Tag value. Must not be null/whitespace. + /// This builder, for fluent chaining. + public TestAppraiserBuilder WithTag(string tag) + { + tag.ThrowIfNullOrWhiteSpace(nameof(tag)); + + _tag = tag; + return this; + } + + /// + /// Configures the appraiser to return the supplied + /// for every call to + /// . + /// + /// Rating container to return. + /// This builder, for fluent chaining. + public TestAppraiserBuilder WithRating(RatingDataContainer rating) + { + rating.ThrowIfNull(nameof(rating)); + + _getRatingsHandler = _ => rating; + return this; + } + + /// + /// Configures the appraiser to compute a rating from the supplied + /// delegate. Useful for tests that need + /// per- rating logic. + /// + /// Delegate that produces a rating container. + /// This builder, for fluent chaining. + public TestAppraiserBuilder WithRatingFactory( + Func handler) + { + handler.ThrowIfNull(nameof(handler)); + + _getRatingsHandler = handler; + return this; + } + + /// + /// Builds the substitute. If no + /// With* method has been called, the substitute returns + /// whatever AutoFixture / NSubstitute would by default. + /// + public IAppraiser Build() + { + var substitute = _fixture.Create(); + + if (_typeId is not null) + { + substitute.TypeId.Returns(_typeId); + } + + if (_tag is not null) + { + substitute.Tag.Returns(_tag); + } + + if (_getRatingsHandler is not null) + { + var handler = _getRatingsHandler; + substitute + .GetRatings(Arg.Any(), Arg.Any()) + .Returns(ci => handler(ci.ArgAt(0))); + } + + return substitute; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Core/TestCommunicationServiceClientBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Core/TestCommunicationServiceClientBuilder.cs new file mode 100644 index 00000000..bcdca698 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Core/TestCommunicationServiceClientBuilder.cs @@ -0,0 +1,152 @@ +using System.Threading; +using Acolyte.Assertions; +using Acolyte.Common; +using AutoFixture; +using ProjectV.Core.Services.Clients; +using ProjectV.Models.WebServices.Requests; +using ProjectV.Models.WebServices.Responses; + +namespace ProjectV.Tests.Shared.Helpers.Mocks.Core +{ + /// + /// Builder for test doubles + /// backed by AutoFixture + NSubstitute. + /// + /// + /// + /// The production concrete CommunicationServiceClient constructs + /// its and inner token clients + /// in its constructor, which makes it expensive to wire up for a plain + /// unit test. The interface seam + /// is the natural test + /// substitution target — downstream web-service orchestration tests + /// consume the same shape. + /// + /// + /// Tests that need to exercise the production concrete (e.g. HTTP + /// pipeline tests in ProjectV.Core.Tests) construct it directly + /// with a backed by a + /// FakeHttpMessageHandler; they do not use this builder. + /// + /// + public sealed class TestCommunicationServiceClientBuilder + { + private readonly IFixture _fixture; + + private Result? _loginResponse; + private Result? _startJobResponse; + + /// + /// Initializes a new instance of the + /// class. No + /// behavior is configured until one of the With* methods is + /// called. + /// + /// AutoFixture instance to create the substitute. + public TestCommunicationServiceClientBuilder(IFixture fixture) + { + _fixture = fixture.ThrowIfNull(nameof(fixture)); + } + + /// + /// Convenience factory that returns a bare-bones + /// substitute with no + /// configured behavior. + /// + /// AutoFixture instance to create the substitute. + public static ICommunicationServiceClient CreateWithoutSetup(IFixture fixture) + { + fixture.ThrowIfNull(nameof(fixture)); + return new TestCommunicationServiceClientBuilder(fixture).Build(); + } + + /// + /// Configures the substitute to return the supplied successful + /// for every call to + /// . + /// + /// Token response to wrap. Must not be null. + /// This builder, for fluent chaining. + public TestCommunicationServiceClientBuilder WithLoginResponse(TokenResponse response) + { + response.ThrowIfNull(nameof(response)); + + _loginResponse = Result.Ok(response); + return this; + } + + /// + /// Configures the substitute to return the supplied + /// as a login failure for every call to + /// . + /// + /// Error response to wrap. Must not be null. + /// This builder, for fluent chaining. + public TestCommunicationServiceClientBuilder WithLoginError(ErrorResponse error) + { + error.ThrowIfNull(nameof(error)); + + _loginResponse = Result.Error(error); + return this; + } + + /// + /// Configures the substitute to return the supplied successful + /// for every call to + /// . + /// + /// + /// Processing response to wrap. Must not be null. + /// + /// This builder, for fluent chaining. + public TestCommunicationServiceClientBuilder WithStartJobResponse( + ProcessingResponse response) + { + response.ThrowIfNull(nameof(response)); + + _startJobResponse = Result.Ok(response); + return this; + } + + /// + /// Configures the substitute to return the supplied + /// as a job-start failure for every + /// call to . + /// + /// Error response to wrap. Must not be null. + /// This builder, for fluent chaining. + public TestCommunicationServiceClientBuilder WithStartJobError(ErrorResponse error) + { + error.ThrowIfNull(nameof(error)); + + _startJobResponse = Result.Error(error); + return this; + } + + /// + /// Builds the substitute. + /// If no With* method has been called, the substitute returns + /// whatever AutoFixture / NSubstitute would by default. + /// + public ICommunicationServiceClient Build() + { + var substitute = _fixture.Create(); + + if (_loginResponse is { } loginResponse) + { + substitute + .LoginAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(loginResponse)); + } + + if (_startJobResponse is { } startJobResponse) + { + substitute + .StartJobAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(startJobResponse)); + } + + return substitute; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Crawlers/TestOmdbCrawlerBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Crawlers/TestOmdbCrawlerBuilder.cs new file mode 100644 index 00000000..7514c588 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Crawlers/TestOmdbCrawlerBuilder.cs @@ -0,0 +1,172 @@ +using Acolyte.Assertions; +using AutoFixture; +using ProjectV.Crawlers; +using ProjectV.Models.Data; + +namespace ProjectV.Tests.Shared.Helpers.Mocks.Crawlers +{ + /// + /// Builder for test doubles representing an OMDb + /// crawler. Shape matches + /// verbatim; only the + /// differs so downstream tests can distinguish + /// substitutes by tag in CrawlersManager error messages. + /// + public sealed class TestOmdbCrawlerBuilder + { + /// + /// Default tag value returned by the substitute. Mirrors + /// nameof(OmdbCrawler) from the production class. + /// + public const string DefaultTag = "OmdbCrawler"; + + private readonly IFixture _fixture; + + private readonly List _responses = new List(); + private string _tag = DefaultTag; + private Type _typeId = typeof(BasicInfo); + private Exception? _throwOnGetResponse; + + /// + /// Initializes a new instance of the + /// class. No responses are + /// configured until / + /// is called. + /// + /// AutoFixture instance to create the substitute. + public TestOmdbCrawlerBuilder(IFixture fixture) + { + _fixture = fixture.ThrowIfNull(nameof(fixture)); + } + + /// + /// Convenience factory returning a bare + /// substitute with the and an empty + /// response stream. + /// + /// AutoFixture instance to create the substitute. + public static ICrawler CreateWithoutSetup(IFixture fixture) + { + fixture.ThrowIfNull(nameof(fixture)); + return new TestOmdbCrawlerBuilder(fixture).Build(); + } + + /// + /// Registers a single response to be yielded + /// for every call. + /// + /// Response item. Must not be null. + /// This builder, for fluent chaining. + public TestOmdbCrawlerBuilder WithResponse(BasicInfo response) + { + response.ThrowIfNull(nameof(response)); + + _responses.Add(response); + return this; + } + + /// + /// Registers a batch of responses at once. + /// + /// + /// Responses to yield. Must not be null; null elements are + /// rejected. + /// + /// This builder, for fluent chaining. + public TestOmdbCrawlerBuilder WithResponses(IReadOnlyList responses) + { + responses.ThrowIfNull(nameof(responses)); + + foreach (BasicInfo response in responses) + { + response.ThrowIfNull(nameof(responses)); + _responses.Add(response); + } + + return this; + } + + /// + /// Overrides the value returned by the + /// substitute. Defaults to . + /// + /// Tag value. Must not be null/whitespace. + /// This builder, for fluent chaining. + public TestOmdbCrawlerBuilder WithTag(string tag) + { + tag.ThrowIfNullOrWhiteSpace(nameof(tag)); + + _tag = tag; + return this; + } + + /// + /// Overrides the value returned by the + /// substitute. Defaults to typeof(BasicInfo). + /// + /// Type id. Must not be null. + /// This builder, for fluent chaining. + public TestOmdbCrawlerBuilder WithTypeId(Type typeId) + { + typeId.ThrowIfNull(nameof(typeId)); + + _typeId = typeId; + return this; + } + + /// + /// Configures the substitute to throw the supplied exception + /// synchronously from . + /// + /// Exception to throw. Must not be null. + /// This builder, for fluent chaining. + public TestOmdbCrawlerBuilder WithThrowOnGetResponse(Exception exception) + { + exception.ThrowIfNull(nameof(exception)); + + _throwOnGetResponse = exception; + return this; + } + + /// + /// Builds the substitute. Configured response + /// items are yielded asynchronously from + /// . + /// + public ICrawler Build() + { + var substitute = _fixture.Create(); + + substitute.Tag.Returns(_tag); + substitute.TypeId.Returns(_typeId); + + if (_throwOnGetResponse is not null) + { + var exception = _throwOnGetResponse; + substitute + .GetResponse(Arg.Any(), Arg.Any()) + .Returns(_ => throw exception); + } + else + { + IReadOnlyList snapshot = _responses.ToArray(); + substitute + .GetResponse(Arg.Any(), Arg.Any()) + .Returns(_ => ToAsyncEnumerable(snapshot)); + } + + return substitute; + } + + private static async IAsyncEnumerable ToAsyncEnumerable( + IReadOnlyList items) + { + foreach (BasicInfo item in items) + { + yield return item; + } + + await Task.CompletedTask; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Crawlers/TestSteamCrawlerBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Crawlers/TestSteamCrawlerBuilder.cs new file mode 100644 index 00000000..84f9755d --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Crawlers/TestSteamCrawlerBuilder.cs @@ -0,0 +1,172 @@ +using Acolyte.Assertions; +using AutoFixture; +using ProjectV.Crawlers; +using ProjectV.Models.Data; + +namespace ProjectV.Tests.Shared.Helpers.Mocks.Crawlers +{ + /// + /// Builder for test doubles representing a Steam + /// crawler. Shape matches + /// verbatim; only the + /// differs so downstream tests can distinguish + /// substitutes by tag in CrawlersManager error messages. + /// + public sealed class TestSteamCrawlerBuilder + { + /// + /// Default tag value returned by the substitute. Mirrors + /// nameof(SteamCrawler) from the production class. + /// + public const string DefaultTag = "SteamCrawler"; + + private readonly IFixture _fixture; + + private readonly List _responses = new List(); + private string _tag = DefaultTag; + private Type _typeId = typeof(BasicInfo); + private Exception? _throwOnGetResponse; + + /// + /// Initializes a new instance of the + /// class. No responses are + /// configured until / + /// is called. + /// + /// AutoFixture instance to create the substitute. + public TestSteamCrawlerBuilder(IFixture fixture) + { + _fixture = fixture.ThrowIfNull(nameof(fixture)); + } + + /// + /// Convenience factory returning a bare + /// substitute with the and an empty + /// response stream. + /// + /// AutoFixture instance to create the substitute. + public static ICrawler CreateWithoutSetup(IFixture fixture) + { + fixture.ThrowIfNull(nameof(fixture)); + return new TestSteamCrawlerBuilder(fixture).Build(); + } + + /// + /// Registers a single response to be yielded + /// for every call. + /// + /// Response item. Must not be null. + /// This builder, for fluent chaining. + public TestSteamCrawlerBuilder WithResponse(BasicInfo response) + { + response.ThrowIfNull(nameof(response)); + + _responses.Add(response); + return this; + } + + /// + /// Registers a batch of responses at once. + /// + /// + /// Responses to yield. Must not be null; null elements are + /// rejected. + /// + /// This builder, for fluent chaining. + public TestSteamCrawlerBuilder WithResponses(IReadOnlyList responses) + { + responses.ThrowIfNull(nameof(responses)); + + foreach (BasicInfo response in responses) + { + response.ThrowIfNull(nameof(responses)); + _responses.Add(response); + } + + return this; + } + + /// + /// Overrides the value returned by the + /// substitute. Defaults to . + /// + /// Tag value. Must not be null/whitespace. + /// This builder, for fluent chaining. + public TestSteamCrawlerBuilder WithTag(string tag) + { + tag.ThrowIfNullOrWhiteSpace(nameof(tag)); + + _tag = tag; + return this; + } + + /// + /// Overrides the value returned by the + /// substitute. Defaults to typeof(BasicInfo). + /// + /// Type id. Must not be null. + /// This builder, for fluent chaining. + public TestSteamCrawlerBuilder WithTypeId(Type typeId) + { + typeId.ThrowIfNull(nameof(typeId)); + + _typeId = typeId; + return this; + } + + /// + /// Configures the substitute to throw the supplied exception + /// synchronously from . + /// + /// Exception to throw. Must not be null. + /// This builder, for fluent chaining. + public TestSteamCrawlerBuilder WithThrowOnGetResponse(Exception exception) + { + exception.ThrowIfNull(nameof(exception)); + + _throwOnGetResponse = exception; + return this; + } + + /// + /// Builds the substitute. Configured response + /// items are yielded asynchronously from + /// . + /// + public ICrawler Build() + { + var substitute = _fixture.Create(); + + substitute.Tag.Returns(_tag); + substitute.TypeId.Returns(_typeId); + + if (_throwOnGetResponse is not null) + { + var exception = _throwOnGetResponse; + substitute + .GetResponse(Arg.Any(), Arg.Any()) + .Returns(_ => throw exception); + } + else + { + IReadOnlyList snapshot = _responses.ToArray(); + substitute + .GetResponse(Arg.Any(), Arg.Any()) + .Returns(_ => ToAsyncEnumerable(snapshot)); + } + + return substitute; + } + + private static async IAsyncEnumerable ToAsyncEnumerable( + IReadOnlyList items) + { + foreach (BasicInfo item in items) + { + yield return item; + } + + await Task.CompletedTask; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Crawlers/TestTmdbCrawlerBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Crawlers/TestTmdbCrawlerBuilder.cs new file mode 100644 index 00000000..8b29f335 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Crawlers/TestTmdbCrawlerBuilder.cs @@ -0,0 +1,185 @@ +using Acolyte.Assertions; +using AutoFixture; +using ProjectV.Crawlers; +using ProjectV.Models.Data; + +namespace ProjectV.Tests.Shared.Helpers.Mocks.Crawlers +{ + /// + /// Builder for test doubles representing a TMDb + /// crawler. Wraps an AutoFixture-created substitute for + /// with canned responses produced via an async + /// enumerable to match the production + /// shape (it returns + /// , not ). + /// + /// + /// Sibling to / + /// . Each ships its own builder so + /// downstream test plans can wire crawler-specific + /// / defaults + /// without re-writing the same boilerplate. + /// + public sealed class TestTmdbCrawlerBuilder + { + /// + /// Default tag value returned by the substitute. Mirrors + /// nameof(TmdbCrawler) from the production class. + /// + public const string DefaultTag = "TmdbCrawler"; + + private readonly IFixture _fixture; + + private readonly List _responses = new List(); + private string _tag = DefaultTag; + private Type _typeId = typeof(BasicInfo); + private Exception? _throwOnGetResponse; + + /// + /// Initializes a new instance of the + /// class. No responses are + /// configured until / + /// is called. + /// + /// AutoFixture instance to create the substitute. + public TestTmdbCrawlerBuilder(IFixture fixture) + { + _fixture = fixture.ThrowIfNull(nameof(fixture)); + } + + /// + /// Convenience factory returning a bare + /// substitute with the , the default + /// typeof(BasicInfo) type id, and an empty response stream. + /// + /// AutoFixture instance to create the substitute. + public static ICrawler CreateWithoutSetup(IFixture fixture) + { + fixture.ThrowIfNull(nameof(fixture)); + return new TestTmdbCrawlerBuilder(fixture).Build(); + } + + /// + /// Registers a single response to be yielded + /// for every call. + /// + /// Response item. Must not be null. + /// This builder, for fluent chaining. + public TestTmdbCrawlerBuilder WithResponse(BasicInfo response) + { + response.ThrowIfNull(nameof(response)); + + _responses.Add(response); + return this; + } + + /// + /// Registers a batch of responses at once. + /// + /// + /// Responses to yield. Must not be null; null elements are + /// rejected. + /// + /// This builder, for fluent chaining. + public TestTmdbCrawlerBuilder WithResponses(IReadOnlyList responses) + { + responses.ThrowIfNull(nameof(responses)); + + foreach (BasicInfo response in responses) + { + response.ThrowIfNull(nameof(responses)); + _responses.Add(response); + } + + return this; + } + + /// + /// Overrides the value returned by the + /// substitute. Defaults to . + /// + /// Tag value. Must not be null/whitespace. + /// This builder, for fluent chaining. + public TestTmdbCrawlerBuilder WithTag(string tag) + { + tag.ThrowIfNullOrWhiteSpace(nameof(tag)); + + _tag = tag; + return this; + } + + /// + /// Overrides the value returned by the + /// substitute. Defaults to typeof(BasicInfo). + /// + /// Type id. Must not be null. + /// This builder, for fluent chaining. + public TestTmdbCrawlerBuilder WithTypeId(Type typeId) + { + typeId.ThrowIfNull(nameof(typeId)); + + _typeId = typeId; + return this; + } + + /// + /// Configures the substitute to throw the supplied exception + /// synchronously from + /// (i.e. before the async enumerable iteration starts). Useful for + /// exercising CrawlersManager.TryGetResponse's log+rethrow + /// behaviour. + /// + /// Exception to throw. Must not be null. + /// This builder, for fluent chaining. + public TestTmdbCrawlerBuilder WithThrowOnGetResponse(Exception exception) + { + exception.ThrowIfNull(nameof(exception)); + + _throwOnGetResponse = exception; + return this; + } + + /// + /// Builds the substitute. Configured response + /// items are yielded asynchronously from + /// . + /// + public ICrawler Build() + { + var substitute = _fixture.Create(); + + substitute.Tag.Returns(_tag); + substitute.TypeId.Returns(_typeId); + + if (_throwOnGetResponse is not null) + { + var exception = _throwOnGetResponse; + substitute + .GetResponse(Arg.Any(), Arg.Any()) + .Returns(_ => throw exception); + } + else + { + IReadOnlyList snapshot = _responses.ToArray(); + substitute + .GetResponse(Arg.Any(), Arg.Any()) + .Returns(_ => ToAsyncEnumerable(snapshot)); + } + + return substitute; + } + + private static async IAsyncEnumerable ToAsyncEnumerable( + IReadOnlyList items) + { + foreach (BasicInfo item in items) + { + yield return item; + } + + // Force the method to be truly asynchronous so callers cannot + // accidentally treat the substitute as a synchronous source. + await Task.CompletedTask; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Net/TestHttpClientFactoryBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Net/TestHttpClientFactoryBuilder.cs new file mode 100644 index 00000000..65838418 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Net/TestHttpClientFactoryBuilder.cs @@ -0,0 +1,75 @@ +using System.Net.Http; +using Acolyte.Assertions; +using AutoFixture; + +namespace ProjectV.Tests.Shared.Helpers.Mocks.Net +{ + /// + /// Builder for test doubles backed by + /// an AutoFixture-supplied substitute. + /// Configures CreateClient(Any) to return a caller-supplied + /// — usually one backed by a fake message + /// handler so the production code's outbound requests can be observed. + /// + public sealed class TestHttpClientFactoryBuilder + { + private readonly IFixture _fixture; + private HttpClient? _httpClient; + + /// + /// Initializes a new instance of the + /// class. No client is + /// configured until is called. + /// + /// AutoFixture instance to create the substitute. + public TestHttpClientFactoryBuilder(IFixture fixture) + { + _fixture = fixture.ThrowIfNull(nameof(fixture)); + } + + /// + /// Convenience factory that returns a bare-bones + /// substitute with no configured + /// CreateClient behavior. + /// + /// AutoFixture instance to create the substitute. + public static IHttpClientFactory CreateWithoutSetup(IFixture fixture) + { + fixture.ThrowIfNull(nameof(fixture)); + return new TestHttpClientFactoryBuilder(fixture).Build(); + } + + /// + /// Configures the factory so every CreateClient(...) call + /// returns the supplied . + /// + /// + /// The to return. Must not be null. + /// + /// This builder, for fluent chaining. + public TestHttpClientFactoryBuilder WithHttpClient(HttpClient httpClient) + { + _httpClient = httpClient.ThrowIfNull(nameof(httpClient)); + return this; + } + + /// + /// Builds the substitute. If + /// has been called, every + /// CreateClient(...) call will return the configured client; + /// otherwise the substitute returns whatever AutoFixture / NSubstitute + /// would by default. + /// + public IHttpClientFactory Build() + { + var factory = _fixture.Create(); + + if (_httpClient is not null) + { + factory.CreateClient(Arg.Any()).Returns(_httpClient); + } + + return factory; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Telegram/TestTelegramBotClientBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Telegram/TestTelegramBotClientBuilder.cs new file mode 100644 index 00000000..e7c27aa3 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Mocks/Telegram/TestTelegramBotClientBuilder.cs @@ -0,0 +1,133 @@ +using System.Threading; +using Acolyte.Assertions; +using AutoFixture; +using Telegram.Bot; +using Telegram.Bot.Requests.Abstractions; +using Telegram.Bot.Types; + +namespace ProjectV.Tests.Shared.Helpers.Mocks.Telegram +{ + /// + /// Builder for test doubles backed by + /// AutoFixture + NSubstitute. Lets a test inject a + /// deterministic bot-client into the + /// host without contacting + /// the live Telegram API. + /// + /// + /// + /// The webhook scenario tests in + /// ProjectV.TelegramBotWebService.Tests/Scenarios/Webhook/ use + /// — the bot handler may call + /// SendMessage / SendTextMessageAsync on the client but the + /// substitute swallows it; the test asserts on the controller's response + /// status, not on outgoing bot calls. + /// + /// + /// The polling scenario tests use — + /// Telegram.Bot 22.x routes the + /// ReceiveAsync extension method through + /// with a + /// GetUpdatesRequest / response type + /// []. Substituting SendRequest<Update[]> + /// is the natural test seam: the first poll yields the configured + /// sequence; subsequent polls yield an empty array (the receiver loops + /// until the supplied + /// signals). + /// + /// + public sealed class TestTelegramBotClientBuilder + { + private readonly IFixture _fixture; + + private readonly List _updateSequence = new List(); + + /// + /// Initializes a new instance of the + /// class. No behavior is + /// configured until is called or + /// is invoked. + /// + /// AutoFixture instance to create the substitute. + public TestTelegramBotClientBuilder(IFixture fixture) + { + _fixture = fixture.ThrowIfNull(nameof(fixture)); + } + + /// + /// Convenience factory returning a bare-bones + /// substitute. The substitute + /// silently absorbs every SendRequest / extension-method call + /// (SendMessage, SetWebhook, etc.) without contacting + /// the real Telegram API — sufficient for webhook scenario tests + /// where the test asserts on the controller response, not on the + /// outgoing bot calls. + /// + /// AutoFixture instance to create the substitute. + public static ITelegramBotClient CreateWithoutSetup(IFixture fixture) + { + fixture.ThrowIfNull(nameof(fixture)); + return new TestTelegramBotClientBuilder(fixture).Build(); + } + + /// + /// Configures the substitute to yield the supplied + /// sequence on the first + /// SendRequest<Update[]> call (i.e. the first poll). + /// Subsequent polls receive an empty array — the long-polling loop + /// will keep going until the caller's + /// signals. + /// + /// + /// Updates to yield on the first poll. Must not be null; + /// null elements are rejected. + /// + /// This builder, for fluent chaining. + public TestTelegramBotClientBuilder WithUpdateSequence(IEnumerable updates) + { + updates.ThrowIfNull(nameof(updates)); + + foreach (Update update in updates) + { + update.ThrowIfNull(nameof(updates)); + _updateSequence.Add(update); + } + + return this; + } + + /// + /// Builds the substitute. If + /// was called, the substitute is + /// pre-configured so the first SendRequest<Update[]> + /// call yields the configured sequence and subsequent calls yield an + /// empty array. + /// + public ITelegramBotClient Build() + { + var substitute = _fixture.Create(); + + if (_updateSequence.Count > 0) + { + Update[] firstBatch = _updateSequence.ToArray(); + Update[] emptyBatch = Array.Empty(); + bool yielded = false; + + substitute + .SendRequest(Arg.Any>(), Arg.Any()) + .Returns(_ => + { + if (yielded) + { + return Task.FromResult(emptyBatch); + } + + yielded = true; + return Task.FromResult(firstBatch); + }); + } + + return substitute; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Appraisers/TestAppraisersManagerBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Appraisers/TestAppraisersManagerBuilder.cs new file mode 100644 index 00000000..15711f32 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Appraisers/TestAppraisersManagerBuilder.cs @@ -0,0 +1,119 @@ +using Acolyte.Assertions; +using ProjectV.Appraisers; + +namespace ProjectV.Tests.Shared.Helpers.Stubs.Appraisers +{ + /// + /// Builder for real instances populated + /// with child doubles. + /// + /// + /// + /// is a sealed concrete class + /// without a substitution-friendly interface seam, so this builder + /// returns a real manager populated through its public + /// API — pre-wired with + /// child substitutes. Use + /// to configure the children + /// (call-shape, return values, etc.). + /// + /// + /// Sibling to ; same shape — one file + /// per public type that needs a test double. + /// + /// + public sealed class TestAppraisersManagerBuilder + { + private readonly List _appraisers = new List(); + private bool _outputResults; + + /// + /// Initializes a new instance of the + /// class. No appraisers + /// are registered until one of the With* methods is called. + /// + public TestAppraisersManagerBuilder() + { + } + + /// + /// Convenience factory that returns an empty + /// with no children registered and + /// outputResults set to false. + /// + public static AppraisersManager CreateWithoutSetup() + { + return new TestAppraisersManagerBuilder().Build(); + } + + /// + /// Sets the outputResults flag on the resulting + /// . + /// + /// + /// Whether the manager should print appraiser results to + /// GlobalMessageHandler. Defaults to false. + /// + /// This builder, for fluent chaining. + public TestAppraisersManagerBuilder WithOutputResults(bool outputResults) + { + _outputResults = outputResults; + return this; + } + + /// + /// Registers an child to be added to the + /// resulting . + /// + /// + /// Appraiser substitute to register. Must not be null. + /// + /// This builder, for fluent chaining. + public TestAppraisersManagerBuilder WithAppraiser(IAppraiser appraiser) + { + appraiser.ThrowIfNull(nameof(appraiser)); + + _appraisers.Add(appraiser); + return this; + } + + /// + /// Registers a batch of children at once. + /// + /// + /// Appraiser substitutes to register. Must not be null; null + /// elements are rejected. + /// + /// This builder, for fluent chaining. + public TestAppraisersManagerBuilder WithAppraisers( + IReadOnlyList appraisers) + { + appraisers.ThrowIfNull(nameof(appraisers)); + + foreach (IAppraiser appraiser in appraisers) + { + appraiser.ThrowIfNull(nameof(appraisers)); + _appraisers.Add(appraiser); + } + + return this; + } + + /// + /// Builds the instance pre-populated + /// with the registered children. The manager is a real production + /// object — children are added via its public + /// method. + /// + public AppraisersManager Build() + { + var manager = new AppraisersManager(_outputResults); + foreach (IAppraiser appraiser in _appraisers) + { + manager.Add(appraiser); + } + + return manager; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Core/TestShellBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Core/TestShellBuilder.cs new file mode 100644 index 00000000..33481d3f --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Core/TestShellBuilder.cs @@ -0,0 +1,158 @@ +using Acolyte.Assertions; +using ProjectV.Appraisers; +using ProjectV.Core; +using ProjectV.Crawlers; +using ProjectV.IO.Input; +using ProjectV.IO.Output; +using ProjectV.Tests.Shared.Helpers.Stubs.Appraisers; +using ProjectV.Tests.Shared.Helpers.Stubs.Managers; + +namespace ProjectV.Tests.Shared.Helpers.Stubs.Core +{ + /// + /// Builder for real instances composed from the four + /// production manager types (, + /// , , + /// ) populated with + /// child doubles. + /// + /// + /// + /// takes concrete-typed managers, not interfaces + /// (a known architectural anti-pattern in this codebase); this builder + /// works around the coupling by composing real managers populated with + /// substituted children via the sibling + /// , + /// , + /// , and + /// classes. + /// + /// + /// The plan does not refactor — the manager-typed + /// constructor parameters stay as production declares them. + /// + /// + public sealed class TestShellBuilder + { + /// + /// Default bounded capacity for the resulting . + /// + public const int DefaultBoundedCapacity = 10; + + private InputManager? _inputManager; + private CrawlersManager? _crawlersManager; + private AppraisersManager? _appraisersManager; + private OutputManager? _outputManager; + private int _boundedCapacity = DefaultBoundedCapacity; + + /// + /// Initializes a new instance of the + /// class. The four manager slots are initially unset; the + /// method fills any unset slot with the empty + /// builder default (CreateWithoutSetup()). + /// + public TestShellBuilder() + { + } + + /// + /// Convenience factory that returns a composed + /// from four empty managers (no inputters, crawlers, appraisers, or + /// outputters registered) and the default bounded capacity. + /// + public static Shell CreateWithoutSetup() + { + return new TestShellBuilder().Build(); + } + + /// + /// Overrides the slot. + /// + /// + /// Pre-built manager to plug into the resulting . + /// Must not be null. + /// + /// This builder, for fluent chaining. + public TestShellBuilder WithInputManager(InputManager inputManager) + { + _inputManager = inputManager.ThrowIfNull(nameof(inputManager)); + return this; + } + + /// + /// Overrides the slot. + /// + /// + /// Pre-built manager to plug into the resulting . + /// Must not be null. + /// + /// This builder, for fluent chaining. + public TestShellBuilder WithCrawlersManager(CrawlersManager crawlersManager) + { + _crawlersManager = crawlersManager.ThrowIfNull(nameof(crawlersManager)); + return this; + } + + /// + /// Overrides the slot. + /// + /// + /// Pre-built manager to plug into the resulting . + /// Must not be null. + /// + /// This builder, for fluent chaining. + public TestShellBuilder WithAppraisersManager(AppraisersManager appraisersManager) + { + _appraisersManager = appraisersManager.ThrowIfNull(nameof(appraisersManager)); + return this; + } + + /// + /// Overrides the slot. + /// + /// + /// Pre-built manager to plug into the resulting . + /// Must not be null. + /// + /// This builder, for fluent chaining. + public TestShellBuilder WithOutputManager(OutputManager outputManager) + { + _outputManager = outputManager.ThrowIfNull(nameof(outputManager)); + return this; + } + + /// + /// Overrides the bounded capacity passed to the + /// constructor. + /// + /// Bounded capacity value. + /// This builder, for fluent chaining. + public TestShellBuilder WithBoundedCapacity(int boundedCapacity) + { + _boundedCapacity = boundedCapacity; + return this; + } + + /// + /// Builds the instance. Any manager slot that + /// has not been explicitly set is filled with the corresponding + /// builder's CreateWithoutSetup() default. + /// + public Shell Build() + { + var inputManager = _inputManager ?? TestInputManagerBuilder.CreateWithoutSetup(); + var crawlersManager = _crawlersManager ?? TestCrawlersManagerBuilder.CreateWithoutSetup(); + var appraisersManager = + _appraisersManager ?? TestAppraisersManagerBuilder.CreateWithoutSetup(); + var outputManager = _outputManager ?? TestOutputManagerBuilder.CreateWithoutSetup(); + + return new Shell( + inputManager, + crawlersManager, + appraisersManager, + outputManager, + _boundedCapacity + ); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/DataPipeline/TestDataflowPipelineBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/DataPipeline/TestDataflowPipelineBuilder.cs new file mode 100644 index 00000000..a302825e --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/DataPipeline/TestDataflowPipelineBuilder.cs @@ -0,0 +1,100 @@ +using Acolyte.Assertions; +using ProjectV.DataPipeline; + +namespace ProjectV.Tests.Shared.Helpers.Stubs.DataPipeline +{ + /// + /// Builder for real instances populated + /// with caller-supplied + + /// stages. + /// is a sealed class with no + /// substitution-friendly interface seam — its constructor takes real + /// flow instances and exposes them as read-only properties, so this + /// builder returns a real pipeline. + /// + /// + /// + /// Wiring an entirely-empty pipeline (no inputters, no outputters) is + /// supported for shape/property tests, but exercising + /// end-to-end requires + /// fully-composed flows because Gridsum.DataflowEx blocks complete only + /// when every upstream dependency has signalled completion (an + /// empty-pipeline hang). + /// + /// + /// Crawlers / Appraisers flows are intentionally NOT carried as + /// properties on ; they are implementation + /// details of in the + /// production path (here, the test caller wires its own composition + /// outside the pipeline when needed for integration coverage). + /// + /// + public sealed class TestDataflowPipelineBuilder + { + private InputtersFlow? _inputtersFlow; + private OutputtersFlow? _outputtersFlow; + + /// + /// Initializes a new instance of the + /// class. No flow stages + /// are configured until one of the With* methods is called. + /// + public TestDataflowPipelineBuilder() + { + } + + /// + /// Convenience factory returning a + /// composed of empty + + /// stages — useful for + /// constructor / property tests that do not exercise + /// . + /// + public static DataflowPipeline CreateWithoutSetup() + { + return new TestDataflowPipelineBuilder().Build(); + } + + /// + /// Overrides the stage. If + /// unset, an empty stage is constructed by . + /// + /// Flow instance. Must not be null. + /// This builder, for fluent chaining. + public TestDataflowPipelineBuilder WithInputtersFlow(InputtersFlow inputtersFlow) + { + inputtersFlow.ThrowIfNull(nameof(inputtersFlow)); + + _inputtersFlow = inputtersFlow; + return this; + } + + /// + /// Overrides the stage. If + /// unset, an empty stage is constructed by . + /// + /// Flow instance. Must not be null. + /// This builder, for fluent chaining. + public TestDataflowPipelineBuilder WithOutputtersFlow(OutputtersFlow outputtersFlow) + { + outputtersFlow.ThrowIfNull(nameof(outputtersFlow)); + + _outputtersFlow = outputtersFlow; + return this; + } + + /// + /// Builds the instance with the + /// configured (or defaulted-empty) stages. + /// + public DataflowPipeline Build() + { + InputtersFlow inputtersFlow = _inputtersFlow + ?? new InputtersFlow(Array.Empty>>()); + OutputtersFlow outputtersFlow = _outputtersFlow + ?? new OutputtersFlow(Array.Empty>()); + + return new DataflowPipeline(inputtersFlow, outputtersFlow); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Managers/TestCrawlersManagerBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Managers/TestCrawlersManagerBuilder.cs new file mode 100644 index 00000000..987ce9a3 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Managers/TestCrawlersManagerBuilder.cs @@ -0,0 +1,105 @@ +using Acolyte.Assertions; +using ProjectV.Crawlers; + +namespace ProjectV.Tests.Shared.Helpers.Stubs.Managers +{ + /// + /// Builder for real instances populated + /// with child doubles. + /// is sealed without a + /// substitution-friendly interface seam, so this + /// builder returns a real manager populated through its public + /// API. + /// + public sealed class TestCrawlersManagerBuilder + { + private readonly List _crawlers = new List(); + private bool _outputResults; + + /// + /// Initializes a new instance of the + /// class. No crawlers are + /// registered until one of the With* methods is called. + /// + public TestCrawlersManagerBuilder() + { + } + + /// + /// Convenience factory that returns an empty + /// with no children registered and + /// outputResults set to false. + /// + public static CrawlersManager CreateWithoutSetup() + { + return new TestCrawlersManagerBuilder().Build(); + } + + /// + /// Sets the outputResults flag on the resulting + /// . + /// + /// + /// Whether the manager should propagate outputResults=true to + /// every child crawler. Defaults to false. + /// + /// This builder, for fluent chaining. + public TestCrawlersManagerBuilder WithOutputResults(bool outputResults) + { + _outputResults = outputResults; + return this; + } + + /// + /// Registers an child to be added to the + /// resulting . + /// + /// + /// Crawler substitute to register. Must not be null. + /// + /// This builder, for fluent chaining. + public TestCrawlersManagerBuilder WithCrawler(ICrawler crawler) + { + crawler.ThrowIfNull(nameof(crawler)); + + _crawlers.Add(crawler); + return this; + } + + /// + /// Registers a batch of children at once. + /// + /// + /// Crawler substitutes to register. Must not be null; null + /// elements are rejected. + /// + /// This builder, for fluent chaining. + public TestCrawlersManagerBuilder WithCrawlers(IReadOnlyList crawlers) + { + crawlers.ThrowIfNull(nameof(crawlers)); + + foreach (ICrawler crawler in crawlers) + { + crawler.ThrowIfNull(nameof(crawlers)); + _crawlers.Add(crawler); + } + + return this; + } + + /// + /// Builds the instance pre-populated + /// with the registered children. + /// + public CrawlersManager Build() + { + var manager = new CrawlersManager(_outputResults); + foreach (ICrawler crawler in _crawlers) + { + manager.Add(crawler); + } + + return manager; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Managers/TestInputManagerBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Managers/TestInputManagerBuilder.cs new file mode 100644 index 00000000..53d01aee --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Managers/TestInputManagerBuilder.cs @@ -0,0 +1,118 @@ +using Acolyte.Assertions; +using ProjectV.IO.Input; + +namespace ProjectV.Tests.Shared.Helpers.Stubs.Managers +{ + /// + /// Builder for real instances populated with + /// child doubles. + /// is sealed without a + /// substitution-friendly interface seam, so this builder + /// returns a real manager populated through its public + /// API. + /// + /// + /// Mirrors — one + /// file per public manager type that needs a test double. The default + /// storage name is a non-empty placeholder because the production + /// constructor calls ThrowIfNullOrWhiteSpace on it. + /// + public sealed class TestInputManagerBuilder + { + /// + /// Default storage name used by and + /// builds that do not call . + /// Non-empty to satisfy the production ctor guard. + /// + public const string DefaultStorageName = "test-input-storage"; + + private readonly List _inputters = new List(); + private string _defaultStorageName = DefaultStorageName; + + /// + /// Initializes a new instance of the + /// class. No inputters are + /// registered until one of the With* methods is called. + /// + public TestInputManagerBuilder() + { + } + + /// + /// Convenience factory that returns an empty + /// with no child inputters registered. + /// + public static InputManager CreateWithoutSetup() + { + return new TestInputManagerBuilder().Build(); + } + + /// + /// Overrides the default storage name passed to the + /// constructor. + /// + /// + /// Storage name. Must not be null, empty, or whitespace. + /// + /// This builder, for fluent chaining. + public TestInputManagerBuilder WithDefaultStorageName(string defaultStorageName) + { + defaultStorageName.ThrowIfNullOrWhiteSpace(nameof(defaultStorageName)); + + _defaultStorageName = defaultStorageName; + return this; + } + + /// + /// Registers an child to be added to the + /// resulting . + /// + /// + /// Inputter substitute to register. Must not be null. + /// + /// This builder, for fluent chaining. + public TestInputManagerBuilder WithInputter(IInputter inputter) + { + inputter.ThrowIfNull(nameof(inputter)); + + _inputters.Add(inputter); + return this; + } + + /// + /// Registers a batch of children at once. + /// + /// + /// Inputter substitutes to register. Must not be null; null + /// elements are rejected. + /// + /// This builder, for fluent chaining. + public TestInputManagerBuilder WithInputters(IReadOnlyList inputters) + { + inputters.ThrowIfNull(nameof(inputters)); + + foreach (IInputter inputter in inputters) + { + inputter.ThrowIfNull(nameof(inputters)); + _inputters.Add(inputter); + } + + return this; + } + + /// + /// Builds the instance pre-populated with + /// the registered children. + /// + public InputManager Build() + { + var manager = new InputManager(_defaultStorageName); + foreach (IInputter inputter in _inputters) + { + manager.Add(inputter); + } + + return manager; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Managers/TestOutputManagerBuilder.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Managers/TestOutputManagerBuilder.cs new file mode 100644 index 00000000..ec835ed7 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/Stubs/Managers/TestOutputManagerBuilder.cs @@ -0,0 +1,116 @@ +using Acolyte.Assertions; +using ProjectV.IO.Output; + +namespace ProjectV.Tests.Shared.Helpers.Stubs.Managers +{ + /// + /// Builder for real instances populated with + /// child doubles. + /// is sealed without a + /// substitution-friendly interface seam, so this + /// builder returns a real manager populated through its public + /// API. + /// + /// + /// The default storage name is a non-empty placeholder because the + /// production constructor calls ThrowIfNullOrWhiteSpace on it. + /// + public sealed class TestOutputManagerBuilder + { + /// + /// Default storage name used by and + /// builds that do not call . + /// Non-empty to satisfy the production ctor guard. + /// + public const string DefaultStorageName = "test-output-storage"; + + private readonly List _outputters = new List(); + private string _defaultStorageName = DefaultStorageName; + + /// + /// Initializes a new instance of the + /// class. No outputters are + /// registered until one of the With* methods is called. + /// + public TestOutputManagerBuilder() + { + } + + /// + /// Convenience factory that returns an empty + /// with no child outputters registered. + /// + public static OutputManager CreateWithoutSetup() + { + return new TestOutputManagerBuilder().Build(); + } + + /// + /// Overrides the default storage name passed to the + /// constructor. + /// + /// + /// Storage name. Must not be null, empty, or whitespace. + /// + /// This builder, for fluent chaining. + public TestOutputManagerBuilder WithDefaultStorageName(string defaultStorageName) + { + defaultStorageName.ThrowIfNullOrWhiteSpace(nameof(defaultStorageName)); + + _defaultStorageName = defaultStorageName; + return this; + } + + /// + /// Registers an child to be added to the + /// resulting . + /// + /// + /// Outputter substitute to register. Must not be null. + /// + /// This builder, for fluent chaining. + public TestOutputManagerBuilder WithOutputter(IOutputter outputter) + { + outputter.ThrowIfNull(nameof(outputter)); + + _outputters.Add(outputter); + return this; + } + + /// + /// Registers a batch of children at once. + /// + /// + /// Outputter substitutes to register. Must not be null; null + /// elements are rejected. + /// + /// This builder, for fluent chaining. + public TestOutputManagerBuilder WithOutputters(IReadOnlyList outputters) + { + outputters.ThrowIfNull(nameof(outputters)); + + foreach (IOutputter outputter in outputters) + { + outputter.ThrowIfNull(nameof(outputters)); + _outputters.Add(outputter); + } + + return this; + } + + /// + /// Builds the instance pre-populated + /// with the registered children. + /// + public OutputManager Build() + { + var manager = new OutputManager(_defaultStorageName); + foreach (IOutputter outputter in _outputters) + { + manager.Add(outputter); + } + + return manager; + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestJwtConfig.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestJwtConfig.cs new file mode 100644 index 00000000..000c55a2 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestJwtConfig.cs @@ -0,0 +1,72 @@ +namespace ProjectV.Tests.Shared.Helpers.WebApi +{ + /// + /// Bundle of test-side JWT configuration values that + /// seeds into the + /// hosted-service configuration so the JWT bearer middleware (which is + /// wired at ConfigureServices time inside + /// AddJtwAuthentication(jwtConfig)) signs and validates tokens + /// with the SAME secret / issuer / audience that + /// uses on the + /// test side. + /// + /// + /// + /// The secret is a base64-encoded value because that is the shape the + /// production JwtOptions.SecretKey contract uses (see + /// Sources/WebServices/ProjectV.CommonWebApi/Authorization/Tokens/Generators/TokenGenerator.cs + /// — it calls ). The default + /// value in this class is a constant test-only key; production secrets + /// never enter the test code. + /// + /// + /// The default and match + /// the appsettings.json baseline for + /// ProjectV.CommunicationWebService so the same factory works + /// against the in-tree configuration if it is partially merged. + /// + /// + public sealed class TestJwtConfig + { + /// + /// Default base64-encoded HMAC SHA-256 key used by integration tests. + /// This is a literal test value — never reused outside the test suite. + /// + public const string DefaultSecretKeyBase64 = + "VGVzdC1Pbmx5LUp3dC1TZWNyZXQtRm9yLVByb2plY3RWLUludGVnci10ZXN0cy0wMQ=="; + + /// + /// Default token issuer; aligned with the production + /// appsettings.json baseline. + /// + public const string DefaultIssuer = "https://localhost"; + + /// + /// Default token audience; aligned with the production + /// appsettings.json baseline. + /// + public const string DefaultAudience = "https://localhost"; + + /// + /// Gets the base64-encoded signing key. + /// + public string SecretKey { get; init; } = DefaultSecretKeyBase64; + + /// + /// Gets the JWT iss claim value. + /// + public string Issuer { get; init; } = DefaultIssuer; + + /// + /// Gets the JWT aud claim value. + /// + public string Audience { get; init; } = DefaultAudience; + + /// + /// Initializes a new instance of . + /// + public TestJwtConfig() + { + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestJwtHelper.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestJwtHelper.cs new file mode 100644 index 00000000..ea4cba83 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestJwtHelper.cs @@ -0,0 +1,129 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Acolyte.Assertions; +using Microsoft.IdentityModel.Tokens; + +namespace ProjectV.Tests.Shared.Helpers.WebApi +{ + /// + /// Test helper that mints bearer tokens accepted by the real + /// AddJtwAuthentication middleware. The helper signs each token + /// with the same base64-encoded HMAC SHA-256 key, issuer, and audience + /// that seeds into the + /// hosted-service configuration — so the production + /// TokenValidationParameters accept the token end-to-end without + /// any test-only bypass. + /// + /// + /// + /// The shape mirrors + /// Sources/WebServices/ProjectV.CommonWebApi/Authorization/Tokens/Generators/TokenGenerator.cs + /// — same claim layout (), same + /// alias, same + /// iss/aud values. + /// + /// + /// All defaulted parameters point at so any + /// test can change a single field (e.g. userId) without rebuilding + /// the whole bundle. + /// + /// + public static class TestJwtHelper + { + /// + /// Generates a signed bearer token suitable for use as the value of + /// the HTTP Authorization header + /// ("Bearer " + ). + /// + /// + /// Base64-encoded HMAC SHA-256 key. Must match the production + /// JwtOptions.SecretKey value seeded into the host's + /// configuration by the test factory. + /// + /// + /// iss claim value. Must match the production + /// JwtOptions.Issuer. + /// + /// + /// aud claim value. Must match the production + /// JwtOptions.Audience. + /// + /// + /// Optional value to populate the + /// claim. Mirrors how the production TokenGenerator stamps the + /// user id into the access token. + /// + /// + /// Optional value to populate the claim. + /// + /// + /// Optional lifetime offset; defaults to five minutes. Tokens with + /// non-positive expiry let callers exercise the ValidateLifetime + /// rejection path. + /// + /// The serialised JWT bearer token. + public static string GenerateTestBearerToken( + string secretKey, + string issuer, + string audience, + string? userId = null, + string? userName = null, + TimeSpan? expiry = null) + { + secretKey.ThrowIfNullOrWhiteSpace(nameof(secretKey)); + issuer.ThrowIfNullOrWhiteSpace(nameof(issuer)); + audience.ThrowIfNullOrWhiteSpace(nameof(audience)); + + var key = new SymmetricSecurityKey(Convert.FromBase64String(secretKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256Signature); + + var claims = new List(); + if (!string.IsNullOrEmpty(userId)) + { + claims.Add(new Claim(ClaimTypes.NameIdentifier, userId)); + } + if (!string.IsNullOrEmpty(userName)) + { + claims.Add(new Claim(ClaimTypes.Name, userName)); + } + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + notBefore: DateTime.UtcNow.AddSeconds(-1), + expires: DateTime.UtcNow.Add(expiry ?? TimeSpan.FromMinutes(5)), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + /// + /// Convenience overload that signs a token with the values bundled + /// in a instance. + /// + /// Bundle of test-side signing material. + /// Optional value for the user-id claim. + /// Optional value for the user-name claim. + /// Optional token lifetime; default five minutes. + /// The serialised JWT bearer token. + public static string GenerateTestBearerToken( + TestJwtConfig config, + string? userId = null, + string? userName = null, + TimeSpan? expiry = null) + { + config.ThrowIfNull(nameof(config)); + + return GenerateTestBearerToken( + secretKey: config.SecretKey, + issuer: config.Issuer, + audience: config.Audience, + userId: userId, + userName: userName, + expiry: expiry + ); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestWebApplicationFactory.cs b/Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestWebApplicationFactory.cs new file mode 100644 index 00000000..84fda10a --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Helpers/WebApi/TestWebApplicationFactory.cs @@ -0,0 +1,195 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using ProjectV.Core.Services.Clients; +using Telegram.Bot; + +namespace ProjectV.Tests.Shared.Helpers.WebApi +{ + /// + /// Generic wrapper used + /// by every ProjectV web-service integration suite. The factory wires up + /// the host with deterministic test-side overrides: + /// + /// + /// + /// + /// + /// base64 secret + issuer + audience are + /// injected into the host's BEFORE the + /// Startup.ConfigureServices call that wires + /// AddJtwAuthentication(jwtConfig) — this is the only seam + /// that lets us swap the signing key without forking the host code, + /// because the JWT bearer middleware reads the secret at registration + /// time, not on each request. + /// + /// + /// + /// + /// Per-test may layer + /// additional in-memory configuration on top (for example a system + /// user name/password for the JWT login round-trip scenario, or a + /// dummy BotToken so the Telegram bot host can start without + /// a real Telegram API token). + /// + /// + /// + /// + /// is the post-Startup + /// seam — it runs AFTER Startup.ConfigureServices, so DI + /// overrides (e.g. an empty IUserInfoService substitute for + /// scenarios that should NOT include a system user, or an + /// IBotService stub for Telegram tests) replace the + /// production registration. The default delegate is a no-op. + /// + /// + /// + /// + /// is exposed for downstream + /// scenario base classes that need to read the stub back (e.g. to + /// assert outgoing bot calls through Received()). The + /// factory itself does NOT register this stub into the DI + /// container — that lives in the per-family base class because + /// IBotService is defined in the Telegram bot host assembly + /// and we deliberately avoid taking that project reference here. + /// Defaults to null. + /// + /// + /// + /// + /// swaps the + /// production singleton + /// so bot handlers that schedule downstream work do not contact the + /// real CommunicationWebService. Webhook tests leave this + /// null because the webhook path does not touch the comm-client; + /// polling tests supply one built via + /// TestCommunicationServiceClientBuilder. + /// + /// + /// + /// + /// The host environment is forced to + /// so HSTS / HTTPS-redirection branches in Startup.Configure + /// stay out of the way; the test client follows redirects by default. + /// + /// + /// + /// + /// The generic argument is the + /// production Startup class (NOT Program) — ProjectV web + /// services use the non-minimal UseStartup<Startup>() host + /// builder (see Sources/WebServices/ProjectV.CommunicationWebService/Program.cs). + /// + /// + /// + /// The production Startup class type that the test host wraps. + /// + public class TestWebApplicationFactory + : WebApplicationFactory + where TStartup : class + { + /// + /// Gets or sets the JWT signing-material bundle that the factory + /// pushes into the host's so the + /// production AddJtwAuthentication registration accepts + /// tokens signed by . + /// + public TestJwtConfig JwtConfig { get; init; } = new TestJwtConfig(); + + /// + /// Gets or sets extra key-value pairs layered on top of the host's + /// configuration via + /// . + /// Use this to override individual options (e.g. UserServiceOptions + /// for the login-round-trip scenario). + /// + public IReadOnlyDictionary ExtraConfigurationValues { get; init; } = + new Dictionary(); + + /// + /// Gets or sets a hook that lets a per-scenario base class swap or + /// remove DI registrations on top of Startup.ConfigureServices. + /// Defaults to a no-op. + /// + public Action ConfigureTestServices { get; init; } = + _ => { }; + + /// + /// Gets or sets an optional + /// substitute (typically built via TestTelegramBotClientBuilder). + /// The factory itself does not register this stub into DI — that + /// is the responsibility of the per-family base class that knows + /// about IBotService (which lives in the Telegram bot + /// host assembly and is intentionally NOT referenced from + /// ProjectV.Tests.Shared). Defaults to null. + /// + public ITelegramBotClient? TelegramBotClientStub { get; init; } + + /// + /// Gets or sets an optional + /// substitute (typically built via + /// TestCommunicationServiceClientBuilder) that replaces the + /// production registration inside the test host. When + /// non-null, the production + /// transient is removed and re-registered with this stub instance. + /// Defaults to null (production wiring stands — useful when + /// the host does not reach the comm-client on the path under test, + /// e.g. the Telegram webhook path). + /// + public ICommunicationServiceClient? CommunicationServiceClientStub { get; init; } + + /// + /// Initializes a new instance of . + /// + public TestWebApplicationFactory() + { + } + + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + + builder.UseEnvironment(Environments.Development); + + builder.ConfigureAppConfiguration((_, configBuilder) => + { + // The JWT bearer middleware reads the secret/issuer/audience + // INSIDE Startup.ConfigureServices via + // AddJtwAuthentication(jwtConfig). PostConfigure + // runs too late — we need to set the values in + // IConfiguration BEFORE Startup sees them. AddInMemoryCollection + // is layered on top of the production appsettings.json / env + // vars, so it wins. + var overrides = new Dictionary + { + [$"JwtOptions:SecretKey"] = JwtConfig.SecretKey, + [$"JwtOptions:Issuer"] = JwtConfig.Issuer, + [$"JwtOptions:Audience"] = JwtConfig.Audience, + }; + + foreach (var pair in ExtraConfigurationValues) + { + overrides[pair.Key] = pair.Value; + } + + configBuilder.AddInMemoryCollection(overrides); + }); + + builder.ConfigureTestServices(services => + { + if (CommunicationServiceClientStub is not null) + { + services.RemoveAll(); + services.AddSingleton(CommunicationServiceClientStub); + } + + ConfigureTestServices(services); + }); + } + } +} diff --git a/Sources/Tests/ProjectV.Tests.Shared/ProjectV.Tests.Shared.csproj b/Sources/Tests/ProjectV.Tests.Shared/ProjectV.Tests.Shared.csproj new file mode 100644 index 00000000..6569a45f --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/ProjectV.Tests.Shared.csproj @@ -0,0 +1,43 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.Tests.Shared + false + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/Tests/ProjectV.Tests.Shared/Usings/SharedUsings.cs b/Sources/Tests/ProjectV.Tests.Shared/Usings/SharedUsings.cs new file mode 100644 index 00000000..faa34ee3 --- /dev/null +++ b/Sources/Tests/ProjectV.Tests.Shared/Usings/SharedUsings.cs @@ -0,0 +1,20 @@ +#pragma warning disable IDE0005 // Unused global usings are intentional — see remark at the end of this file. +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Threading.Tasks; +global using AwesomeAssertions; +global using NSubstitute; +global using NSubstitute.ExceptionExtensions; +global using NSubstitute.ReceivedExtensions; +global using ProjectV.Tests.Shared.ForTests; +global using Xunit; +#pragma warning restore IDE0005 + +// Remark: this file exposes global usings to every project that references +// ProjectV.Tests.Shared. Namespaces this assembly itself does not exercise +// (System.Collections.Generic, System.Threading.Tasks, +// NSubstitute.ExceptionExtensions, NSubstitute.ReceivedExtensions, +// ProjectV.Tests.Shared.ForTests) are listed because downstream test +// projects rely on them being globally available without their own +// per-csproj using directives. diff --git a/Sources/Tests/ProjectV.TmdbService.Tests/ProjectV.TmdbService.Tests.csproj b/Sources/Tests/ProjectV.TmdbService.Tests/ProjectV.TmdbService.Tests.csproj new file mode 100644 index 00000000..a04bcb0b --- /dev/null +++ b/Sources/Tests/ProjectV.TmdbService.Tests/ProjectV.TmdbService.Tests.csproj @@ -0,0 +1,39 @@ + + + + $(AppPlatforms) + $(AppConfigurations) + Library + $(TestTargetFrameworks) + $(CSharpLangVersion) + ProjectV.TmdbService.Tests + false + false + + + + + + + + + + + + + PreserveNewest + Fixtures\Tmdb\%(RecursiveDir)%(Filename)%(Extension) + + + + diff --git a/Sources/Tests/ProjectV.TmdbService.Tests/TmdbContractTests.cs b/Sources/Tests/ProjectV.TmdbService.Tests/TmdbContractTests.cs new file mode 100644 index 00000000..9877cd5a --- /dev/null +++ b/Sources/Tests/ProjectV.TmdbService.Tests/TmdbContractTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Threading.Tasks; +using AwesomeAssertions; +using ProjectV.Tests.Shared.ForTests; +using ProjectV.Tests.Shared.Helpers.Fixtures; +using ProjectV.TmdbService.Models; +using TMDbLib.Client; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace ProjectV.TmdbService.Tests +{ + /// + /// Contract-stage tests for . + /// Drives the real TMDbLib HTTP pipeline against an in-process + /// that serves recorded JSON fixtures from + /// Sources/Tests/Fixtures/Tmdb/. No live API calls; per-adapter + /// failure isolation keeps a misbehaving fixture from cascading into + /// other provider suites. + /// + /// + /// The TMDbLib ctor accepts a baseUrl + /// parameter (host:port, no scheme — TMDbLib prefixes http:// when + /// useSsl: false). Constructing the SDK client with the WireMock + /// server's host:port lets the real TMDb HTTP plumbing run end-to-end + /// (request building, query-string composition, JSON deserialization, + /// internal retry policy) while the bytes on the wire are sourced from + /// pinned in-repo fixtures. + /// + /// The production wrapper + /// exposes TrySearchMovieAsync and GetConfigAsync (no + /// GetMovieAsync(int) exists despite the plan wording — the SUT + /// surface is verified against the actual public API). + /// + [Trait("Category", "Contract")] + public sealed class TmdbContractTests : BaseMockTest, IAsyncLifetime + { + private const string SearchMovieFixturePath = "Tmdb/search-movie-success.json"; + private const string SearchMovieEmptyFixturePath = "Tmdb/search-movie-empty.json"; + private const string ConfigurationFixturePath = "Tmdb/configuration-success.json"; + + private readonly WireMockServer _server; + private readonly TmdbClient _sut; + + public TmdbContractTests() + { + // Random localhost port; lifecycle owned by IAsyncLifetime hooks. + _server = WireMockServer.Start(); + + // useSsl: false so the SDK speaks plain HTTP to the local stub. + // baseUrl: WireMock's host:port (TMDbLib prefixes http:// itself). + // WireMockServer.Url is non-null after Start() returns; the + // declared type is string? for the lifecycle-pre-start state. + string wireMockUrl = _server.Url!; + var uri = new Uri(wireMockUrl); + string hostPort = $"{uri.Host}:{uri.Port}"; + _sut = new TmdbClient( + apiKey: "test-key", + useSsl: false, + baseUrl: hostPort + ); + } + + public Task InitializeAsync() + { + // Stub /3/search/movie GET → recorded success container. + // Use raw-string body via FixtureLoader (NOT WithBodyAsJson + + // JObject.Parse) — avoids WireMock.Net serializer / Newtonsoft.Json + // casing conflict that mangles property names. + string searchSuccess = FixtureLoader.LoadJsonFixture(SearchMovieFixturePath); + _server + .Given(Request.Create().WithPath("/3/search/movie").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json; charset=utf-8") + .WithBody(searchSuccess)); + + // Stub /3/configuration GET → recorded configuration envelope. + string configurationSuccess = FixtureLoader.LoadJsonFixture(ConfigurationFixturePath); + _server + .Given(Request.Create().WithPath("/3/configuration").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json; charset=utf-8") + .WithBody(configurationSuccess)); + + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + _sut.Dispose(); + _server.Stop(); + _server.Dispose(); + return Task.CompletedTask; + } + + /// + /// Verifies that drives a + /// real HTTP GET against /3/search/movie, deserialises the + /// recorded fixture, and returns a populated + /// with the expected sentinel id. + /// + [Fact] + public async Task TrySearchMovieAsyncReturnsExpectedContainer() + { + // Arrange. + const string query = "synthetic"; + const int expectedThingId = 12345; + + // Act. + TmdbSearchContainer? actualValue = await _sut.TrySearchMovieAsync(query); + + // Assert. + actualValue.Should().NotBeNull(); + actualValue!.Results.Should().HaveCount(1); + actualValue.Results[0].ThingId.Should().Be(expectedThingId); + actualValue.Results[0].Title.Should().NotBeNullOrWhiteSpace(); + _server.LogEntries.Should().HaveCount(1, + "TmdbClient should make exactly one HTTP request for a successful search " + + "(no internal retry on a 200 response)"); + } + + /// + /// Verifies that a zero-results TMDb response (empty results + /// array) is deserialised into an empty + /// — the SDK does not return null + /// for a well-formed empty envelope; the production wrapper preserves + /// that behaviour. + /// + [Fact] + public async Task TrySearchMovieAsyncEmptyResultReturnsEmptyContainer() + { + // Arrange — override the success stub with the empty-envelope fixture. + _server.Reset(); + string searchEmpty = FixtureLoader.LoadJsonFixture(SearchMovieEmptyFixturePath); + _server + .Given(Request.Create().WithPath("/3/search/movie").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json; charset=utf-8") + .WithBody(searchEmpty)); + + // Act. + TmdbSearchContainer? actualValue = await _sut.TrySearchMovieAsync("no-such-movie"); + + // Assert. + actualValue.Should().NotBeNull(); + actualValue!.Results.Should().BeEmpty(); + actualValue.TotalResults.Should().Be(0); + _server.LogEntries.Should().HaveCount(1, + "TmdbClient should make exactly one HTTP request for the empty-result path " + + "(no internal retry on a well-formed 200)"); + } + + /// + /// Verifies that drives a + /// real HTTP GET against /3/configuration, deserialises the + /// recorded fixture, and surfaces the image base URL + poster sizes + /// through the mapped + /// TmdbServiceConfigurationInfo. + /// + [Fact] + public async Task GetConfigAsyncReturnsExpectedConfig() + { + // Act. + var actualValue = await _sut.GetConfigAsync(); + + // Assert. + actualValue.Should().NotBeNull(); + actualValue.BaseUrl.Should().Be("http://image.example.test/t/p/"); + actualValue.SecureBaseUrl.Should().Be("https://image.example.test/t/p/"); + actualValue.PosterSizes.Should().NotBeEmpty(); + actualValue.BackdropSizes.Should().NotBeEmpty(); + _server.LogEntries.Should().HaveCount(1, + "TmdbClient should make exactly one HTTP request for the configuration fetch"); + } + } +} diff --git a/Sources/Tests/coverlet.runsettings b/Sources/Tests/coverlet.runsettings new file mode 100644 index 00000000..ee2319e6 --- /dev/null +++ b/Sources/Tests/coverlet.runsettings @@ -0,0 +1,16 @@ + + + + + + + + + cobertura + [ProjectV.DesktopApp]*,[ProjectV.*.Tests]* + **/Migrations/**/*.cs,**/*.Designer.cs + + + + + diff --git a/Sources/WebServices/ProjectV.ProcessingWebService/ProjectV.ProcessingWebService.csproj b/Sources/WebServices/ProjectV.ProcessingWebService/ProjectV.ProcessingWebService.csproj index 0fb27084..a32b468d 100644 --- a/Sources/WebServices/ProjectV.ProcessingWebService/ProjectV.ProcessingWebService.csproj +++ b/Sources/WebServices/ProjectV.ProcessingWebService/ProjectV.ProcessingWebService.csproj @@ -21,6 +21,17 @@ + + + + +