Files
Musify/docs/development.md
T

156 lines
7.1 KiB
Markdown

# Development environment
How to run everything locally and test it. The full deployment details (dev
and prod) live in [deploy/README.md](../deploy/README.md).
## Startup
Bring up the separate `Infrastructure` repository first (Postgres, Zitadel,
SeaweedFS, RabbitMQ, Jaeger, see its own README for the exact command), then:
```sh
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
```
That runs Musify's own one-shot bootstrap jobs: it generates the RS256
stream-ticket keys in `deploy/keys/`, applies the EF Core migrations, creates
Musify's storage bucket, and provisions Zitadel (project + OIDC apps),
writing the client ids into `deploy/.env`. Full details, including how to run
the apps in Docker with `--profile apps`, are in
[deploy/README.md](../deploy/README.md).
## Applications
```powershell
dotnet run --project backend/Hosts/Musify.Api # :5111
dotnet run --project backend/Hosts/Musify.StreamingGateway # :8081
dotnet run --project backend/Hosts/Musify.Worker # background
npm --prefix web-player run dev # :5173
```
## Port map
| Service | Port |
|---|---|
| Musify.Api | 5111 (`/scalar/v1`, `/openapi/v1.json`) |
| Musify.StreamingGateway | 8081 (`/health`, `/media/...`) |
| web-player | 5173 (`npm run dev`) or 3000 (container) |
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not run by this repo.
Their ports are documented in the separate `Infrastructure` repository's own
README.
DB: `musify_db`, user `postgres`/`postgres` on Infrastructure's shared
Postgres. S3 bucket: `webapi-storage` (credentials
`admin_access_key`/`admin_secret_key`, also Infrastructure's). All of these
come from `deploy/.env`.
## Migrations
The `migrate` service in `deploy/compose.yml` applies them on every `docker
compose up -d` (a no-op if the schema is already current). To run them by
hand after a schema change, against the local database:
```powershell
dotnet ef database update --project backend/Core/Musify.Infrastructure --startup-project backend/Core/Musify.Infrastructure --context Database
```
`Musify.Infrastructure` is both the migrations project and the startup
project: it has the `IDesignTimeDbContextFactory` and the `EFCore.Design`
package. The connection string is read from its user-secrets, or from the
`Database__ConnectionString` environment variable if there are no
user-secrets (which is how the `migrate` container uses it, see
`backend/Musify.Migrator.Dockerfile`).
## Stream tickets (RS256 keys)
The API signs stream tickets with the private key, and the gateway validates
them with the public one. The `keys-init` service in `deploy/compose.yml`
generates them in `deploy/keys/` if they don't exist yet; to do it by hand:
```powershell
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out deploy/keys/stream_private.pem
openssl rsa -in deploy/keys/stream_private.pem -pubout -out deploy/keys/stream_public.pem
```
- API: `StreamTicket:PrivateKeyPath`.
- Gateway: `StreamTicket:PublicKeyPath`.
## Tests
```sh
dotnet test backend/Musify.slnx
```
`backend/Tests/` (the `/Tests/` folder in the `.slnx`) has five xUnit projects:
| Project | Covers | Docker |
|---|---|---|
| `Musify.Domain.Tests` | Value object logic (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Entities are mostly anemic, with no behavior of their own beyond that. | No |
| `Musify.Application.Tests` | The ~45 handlers and services in `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes). | No |
| `Musify.Infrastructure.Tests` | `Database`/migrations, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `PlayListPresetSeeder`. | **Yes** |
| `Musify.Api.Tests` | Real HTTP endpoints (routing, auth, `ValidationFilter`, `ErrorOr`→HTTP mapping) via `WebApplicationFactory`, plus `ErrorOrHttpExtensions`/`CurrentUser`/FluentValidation validators in isolation. | **Yes** |
| `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) and `TicketValidationMiddleware` (traversal, prefix limits, ticket extraction). | No |
`Musify.Worker` has no test project: its `Program.cs` is pure DI/host wiring
(Hangfire, MassTransit, jobs). The actual logic it runs lives in
`Musify.Infrastructure`, already covered there.
### Application.Tests: why in-memory SQLite instead of mocks
`IDatabase` exposes `DbSet<T>` directly, so handlers build real LINQ
(`Where`, `Include`, `Select`...) against it. That can't be mocked with
NSubstitute, since there's no `IQueryable` behind a mock. Instead,
`TestSupport/TestDatabase.cs` is a real `DbContext` on top of in-memory
SQLite (a fresh `:memory:` connection per test, with real foreign keys,
unlike EF Core's InMemory provider, SQLite also supports transactions,
which two handlers rely on). `TestSupport/TestEntities.cs` and
`TestConfigurations.cs` build entities and configuration with sensible
defaults. `IStorageService`, `IEventBus`, etc. are mocked with NSubstitute,
since those are plain interfaces.
### Infrastructure.Tests and Api.Tests: Testcontainers
These two need Docker running. They spin up a real Postgres
(`Testcontainers.PostgreSql`) and, in `Infrastructure.Tests`, a real
SeaweedFS too (the same `chrislusf/seaweedfs:latest` image/command as
`deploy/compose.yml`, not MinIO), so `StorageService` is tested against the
same S3-compatible backend production uses, not against AWS S3. Containers
are started once per run (`ICollectionFixture`), with real EF Core
migrations applied against the ephemeral Postgres.
`Api.Tests` uses `WebApplicationFactory<Program>` against that same
Postgres, with real routing, auth, and validation, but replaces the services
that talk over the network with fakes (`IStorageService`, `IEventBus`,
`IStreamTicketService`), and swaps the real JWT auth scheme for a test
handler (see `TestSupport/FakeAuthenticationHandler.cs`) that authenticates
based on a header, with no real Zitadel needed.
Writing these tests against the real backend found and fixed two actual
bugs (not staged ones): `StorageService.RemoveFolderAsync` threw a
`NullReferenceException` against SeaweedFS (its `DeleteObjects` response
omits the `<Error>` list when there are no errors, unlike AWS S3), and
`AuditableEntityInterceptor` never updated `UpdatedAt` on a normal update
(load → modify → save) because it ran before EF had detected the pending
changes.
## Config (appsettings)
Convention: `appsettings.json` is the template, with empty or neutral
values; `appsettings.Development.json` has the real dev values; in Docker
everything is overridden with environment variables (`Section__Key`) from
`deploy/compose.yml`. To run `Musify.Api` from the IDE, the `Authentication`
values go into user-secrets (not the repo). Copy them from `deploy/.env`
once `zitadel-init` has generated them (see
[deploy/README.md](../deploy/README.md)).
Key sections: `Authentication` (Zitadel), `Database`, `MassTransit`,
`InfrastructureStorage` (S3), `ApplicationStorage` (bucket), `Track`, `PlayList`,
`UploadIntent`, `AudioTranscoder`, `Workers`, `StreamGateway` / `StreamTicket`.
## Host requirements
- .NET 10 SDK and Node 22.
- **ffmpeg** on the PATH (for the Worker's audio transcoding).
- Docker Desktop.