Same treatment as AlbumApplicationResponse, but Track is the cleaner
case: OwnerUserId (local-only) and ExternalId (external-only) are
both genuinely exclusive to one side, so the base carries neither and
is abstract — every real value is one of the two derived types.
TrackApplicationResponse (abstract, shared fields + FromEntity dispatch)
LocalTrackApplicationResponse (+ OwnerUserId)
ExternalTrackApplicationResponse (+ ExternalId)
Hit a real gotcha along the way: the response already had an explicit
Source property, which collides with the "source" discriminator STJ's
polymorphism support injects ("contains property 'source' that
conflicts with an existing metadata property name" — only surfaced at
runtime in the Api integration tests, not at build time). Fixed by
dropping the now-redundant explicit Source field entirely; the
discriminator already serializes the identical "Local"/"YouTube"
value under the same name, so the wire contract is unchanged. Updated
the two call sites that read .Source directly (TrackSearchItemResponse
and a playlist track filter that needed flatMap instead of
filter().map() to keep the narrowing across the chain).
New Playback:AllowAnonymousListening / Playback:AnonymousFragmentSeconds
settings control whether a request without a session can stream at all,
and if so, whether it's limited to a fragment instead of the full track:
- GET /tracks/{id}/stream and GET /youtube/tracks/{videoId}/stream no
longer require authorization outright; the handlers gate on the config
instead, so both keep working exactly as before when it's off (the
default).
- The fragment cap is enforced by the Streaming Gateway, not the client:
the stream ticket now carries an optional maxBytes claim (computed from
AnonymousFragmentSeconds and the transcoder's known bitrate), and
TicketValidationMiddleware clamps the Range it forwards to SeaweedFS to
stay under it — synthesizing one if the request didn't send one, and
answering 416 outright if the requested range starts past the cap.
- A YouTube track that hasn't been provisioned server-side yet streams
straight from YouTube's CDN, bypassing the Gateway entirely — there's no
way to cap that, so an anonymous request for one is refused (409) instead
of silently handing out an uncapped stream.
- ListeningHistory has a non-nullable UserId, so anonymous plays simply
don't get recorded rather than trying to force a row through the FK.
Also fixes a real, pre-existing bug this surfaced: the AppSettings.Development.json
of the Api, Worker, and StreamingGateway all pointed StreamTicket/Workers
paths at "../../deploy/..." — one directory short of deploy/'s actual
location at the repo root relative to each project's own folder. Docker
never hit this (it mounts an absolute /keys path), so it had apparently
never been exercised via `dotnet run` before.
GET /tracks now merges the local catalog page with a live YouTube Music
search (deduplicated against tracks already provisioned locally) whenever a
name filter is given, appending the YouTube hits after the local matches and
carrying a continuation token for further pages. Each item is tagged with its
source via the new TrackSearchItemResponse/TracksSearchResponse shapes,
wrapping the existing TrackApplicationResponse or a raw YouTubeSongResult so
neither type had to change.
GET /youtube/search is removed along with its now-unused handler; the
web-player's explore page, its /api/tracks proxy, and the YouTube filler
helper are updated to call the combined endpoint instead of hitting the two
separately.
Converts all 73 test source files from file-scoped (namespace X;) to
block-scoped (namespace X { ... }) declarations, re-indenting their
contents accordingly. Matches the existing convention already used in
several production files (e.g. Musify.Infrastructure.Configuration,
Musify.Application.Contracts).
Mechanical change only, done with a script, verified with a full
rebuild (0 warnings, 0 errors) and test run (257/257 passing).
- Directory.Packages.props: xunit 2.9.3 -> xunit.v3 4.0.0, bump
xunit.runner.visualstudio to the matching 4.0.0
- All 5 test csproj: xunit -> xunit.v3 package reference, add
<OutputType>Exe</OutputType> (v3 test projects are standalone
executables)
- backend/global.json: opt dotnet test into the new MTP-native mode
(.NET 10 SDK removed the old VSTest bridge for MTP-based test
projects like xunit v3)
- IAsyncLifetime implementations (HandlerTestBase, ApiTestFixture,
InfrastructureTestFixture, StorageServiceTests): v3's IAsyncLifetime
now IS IAsyncDisposable, so InitializeAsync/DisposeAsync return
ValueTask instead of Task. ApiTestFixture specifically now overrides
WebApplicationFactory.DisposeAsync instead of implementing
IAsyncLifetime.DisposeAsync explicitly, since v3 collapses both to
the same single DisposeAsync slot
- Replaced CancellationToken.None with TestContext.Current.CancellationToken
across the suite (a v3-only API) and added it to every call that
was silently omitting a CancellationToken argument, per the new
xUnit1051 analyzer warnings that came with the v3 package
Verified: dotnet build (0 warnings, 0 errors) and dotnet test, 250/250
passing under the new MTP runner.
- StorageServiceTests: replace out-param + manual dispose with IAsyncLifetime
- PictureServiceTests: Assert.True bound checks -> Assert.InRange
- CreateAlbumCommandHandlerTests: assert the specific NotFound error type
instead of just IsError, matching what the test name promises
- CreateAlbumRequestValidatorTests: replace a boundary Theory that computed
its expected result inline with explicit Facts against the real
AlbumReleaseYear constants
- Remove unused using directives across a handful of production and test
files found during the review
Full suite verified green: 250/250 tests passing, 0 build warnings.
Two xUnit projects, wired into Musify.slnx under a /Tests/ folder that
mirrors Core/ and Hosts/:
Musify.Domain.Tests Value-object logic (TrackAudio/TrackPictures'
IsProcessed/IsFailed/IsInProgress). Entities are
otherwise anemic EF models with no behavior to
test.
Musify.Application.Tests All ~45 Application handlers and services
(Albums, Tracks, PlayLists, Users, Mixes,
YouTube, plus TrackStreamIssuer,
UploadIntentValidator, YouTubeTrackProvisioner).
IDatabase exposes DbSet<T> directly, so handlers compose real LINQ queries
against it — that can't be expressed with a mock (no IQueryable provider
behind one). TestSupport/TestDatabase.cs is instead a real DbContext on a
private SQLite in-memory connection per test (fresh per test since xUnit
instantiates the test class per test method): fast, no external dependency,
and — unlike EF Core's InMemory provider — SQLite enforces real foreign keys
and supports transactions, which two handlers need. External dependencies
(IStorageService, IEventBus, IYouTubeMusicService, ...) are substituted with
NSubstitute.
TestSupport/TestEntities.cs and TestConfigurations.cs build fully-populated
domain entities and configuration POCOs with sensible defaults for every
required member, so no test method needs to fill in properties it doesn't
care about, and no builder ever hands back null.
Also, needed to make the Application layer itself testable without a real
Postgres or user-secrets:
- DatabaseDesignTimeFactory now falls back to the Database__ConnectionString
env var, and DesignSettings.json is optional — unblocks the `migrate`
one-shot container from deploy/compose.yml, which hit the same gap.
- Directory.Packages.props: xunit, xunit.runner.visualstudio, NSubstitute,
Microsoft.EntityFrameworkCore.Sqlite (pinned to the same 10.0.8 as the rest
of EF Core), Microsoft.NET.Test.Sdk, coverlet.collector. Also pins
SQLitePCLRaw.bundle_e_sqlite3 to 2.1.13 — EFCore.Sqlite 10.0.8 pulls 2.1.11
transitively, which has a known high-severity advisory (GHSA-2m69-gcr7-jv3q).
- backend/.dockerignore excludes Tests/: no application Dockerfile needs it
in its build context.
162 tests, all green: `dotnet test backend/Musify.slnx`.