Add backend test suite under backend/Tests/
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`.
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Builds fully-populated domain entities for tests, with sensible defaults for every
|
||||
/// <c>required</c> member. Every factory always returns a valid instance — never null — so
|
||||
/// callers never need a null check on what this class hands back.
|
||||
/// </summary>
|
||||
public static class TestEntities
|
||||
{
|
||||
public static User User(
|
||||
long id = 1,
|
||||
string name = "test-user",
|
||||
string? firstName = "Test",
|
||||
string? secondName = "User",
|
||||
string? profilePictureUrl = null) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
NormalizedName = name.ToUpperInvariant(),
|
||||
FirstName = firstName,
|
||||
SecondName = secondName,
|
||||
ProfilePictureUrl = profilePictureUrl
|
||||
};
|
||||
|
||||
public static TrackPictures ProcessedPictures() => new()
|
||||
{
|
||||
OriginalName = "original.webp",
|
||||
SmallName = "small.webp",
|
||||
MediumName = "medium.webp",
|
||||
LargeName = "large.webp",
|
||||
ProcessingStatus = ProcessingStatus.Completed
|
||||
};
|
||||
|
||||
public static TrackPictures PendingPictures() => new()
|
||||
{
|
||||
OriginalName = "original.webp",
|
||||
ProcessingStatus = ProcessingStatus.Pending
|
||||
};
|
||||
|
||||
public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new()
|
||||
{
|
||||
OriginalName = "original.mp3",
|
||||
FolderName = folderName,
|
||||
TranscodeStatus = ProcessingStatus.Completed
|
||||
};
|
||||
|
||||
public static TrackAudio PendingAudio() => new()
|
||||
{
|
||||
OriginalName = "original.mp3",
|
||||
TranscodeStatus = ProcessingStatus.Pending
|
||||
};
|
||||
|
||||
public static LocalTrack LocalTrack(
|
||||
User owner,
|
||||
string title = "Test Track",
|
||||
double durationSeconds = 180,
|
||||
TrackPictures? pictures = null,
|
||||
TrackAudio? audio = null,
|
||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
DurationSeconds = durationSeconds,
|
||||
LifeCycleStatus = lifeCycleStatus,
|
||||
OwnerUserId = owner.Id,
|
||||
Owner = owner,
|
||||
Pictures = pictures ?? ProcessedPictures(),
|
||||
Audio = audio ?? ProcessedAudio()
|
||||
};
|
||||
|
||||
public static ExternalTrack ExternalTrack(
|
||||
string externalId = "video-id",
|
||||
string title = "Test External Track",
|
||||
double durationSeconds = 180,
|
||||
TrackSource source = TrackSource.YouTube,
|
||||
TrackPictures? pictures = null,
|
||||
TrackAudio? audio = null,
|
||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
DurationSeconds = durationSeconds,
|
||||
LifeCycleStatus = lifeCycleStatus,
|
||||
Source = source,
|
||||
ExternalId = externalId,
|
||||
Pictures = pictures ?? ProcessedPictures(),
|
||||
Audio = audio ?? ProcessedAudio()
|
||||
};
|
||||
|
||||
public static Artist Artist(
|
||||
string name = "Test Artist",
|
||||
string externalId = "artist-id") =>
|
||||
new()
|
||||
{
|
||||
Name = name,
|
||||
NormalizedName = name.ToUpperInvariant(),
|
||||
ExternalId = externalId
|
||||
};
|
||||
|
||||
public static PlayListPictures PlayListPictures() => new()
|
||||
{
|
||||
OriginalName = "original.webp",
|
||||
SmallName = "small.webp",
|
||||
MediumName = "medium.webp",
|
||||
LargeName = "large.webp"
|
||||
};
|
||||
|
||||
public static PlayList PlayList(
|
||||
long userId,
|
||||
string name = "Test Playlist",
|
||||
string? description = null,
|
||||
PlayListPictures? pictures = null,
|
||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
||||
new()
|
||||
{
|
||||
UserId = userId,
|
||||
Name = name,
|
||||
NormalizedName = name.ToUpperInvariant(),
|
||||
Description = description,
|
||||
Pictures = pictures ?? PlayListPictures(),
|
||||
LifeCycleStatus = lifeCycleStatus
|
||||
};
|
||||
|
||||
public static AlbumPictures AlbumPictures() => new()
|
||||
{
|
||||
OriginalName = "original.webp",
|
||||
SmallName = "small.webp",
|
||||
MediumName = "medium.webp",
|
||||
LargeName = "large.webp"
|
||||
};
|
||||
|
||||
public static UserAlbum UserAlbum(
|
||||
long ownerUserId,
|
||||
string title = "Test Album",
|
||||
string? description = null,
|
||||
int? releaseYear = 2024,
|
||||
AlbumPictures? pictures = null) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
Description = description,
|
||||
ReleaseYear = releaseYear,
|
||||
OwnerUserId = ownerUserId,
|
||||
Pictures = pictures ?? AlbumPictures()
|
||||
};
|
||||
|
||||
public static ExternalAlbum ExternalAlbum(
|
||||
string externalId = "album-id",
|
||||
string title = "Test External Album",
|
||||
TrackSource source = TrackSource.YouTube,
|
||||
string? thumbnailUrl = null) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
Source = source,
|
||||
ExternalId = externalId,
|
||||
ThumbnailUrl = thumbnailUrl
|
||||
};
|
||||
|
||||
public static UploadIntent UploadIntent(
|
||||
long userId,
|
||||
UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture,
|
||||
string bucket = "test-bucket",
|
||||
string key = "temp/object.webp",
|
||||
string objectName = "object.webp",
|
||||
string contentType = "image/webp",
|
||||
long? expectedSizeBytes = 1024,
|
||||
UploadIntentStatus status = UploadIntentStatus.Issued,
|
||||
DateTime? expiresAt = null) =>
|
||||
new()
|
||||
{
|
||||
UserId = userId,
|
||||
Bucket = bucket,
|
||||
Key = key,
|
||||
ObjectName = objectName,
|
||||
ContentType = contentType,
|
||||
ExpectedSizeBytes = expectedSizeBytes,
|
||||
Purpose = purpose,
|
||||
Status = status,
|
||||
ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5)
|
||||
};
|
||||
|
||||
public static Mix Mix(
|
||||
long userId,
|
||||
string title = "Test Mix",
|
||||
string? subtitle = null,
|
||||
int position = 0) =>
|
||||
new()
|
||||
{
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Subtitle = subtitle,
|
||||
Position = position
|
||||
};
|
||||
|
||||
public static MixItem MixItem(
|
||||
Guid mixId,
|
||||
int position = 0,
|
||||
MixItemSource source = MixItemSource.Musify,
|
||||
Guid? trackId = null,
|
||||
string? videoId = null,
|
||||
string title = "Test Mix Item",
|
||||
string? artist = "Test Artist",
|
||||
double durationSeconds = 180,
|
||||
bool isExplicit = false) =>
|
||||
new()
|
||||
{
|
||||
MixId = mixId,
|
||||
Position = position,
|
||||
Source = source,
|
||||
TrackId = trackId,
|
||||
VideoId = videoId,
|
||||
Title = title,
|
||||
Artist = artist,
|
||||
DurationSeconds = durationSeconds,
|
||||
IsExplicit = isExplicit
|
||||
};
|
||||
|
||||
public static ListeningHistory ListeningHistory(
|
||||
long userId,
|
||||
Guid trackId,
|
||||
DateTime? listenedAt = null) =>
|
||||
new()
|
||||
{
|
||||
UserId = userId,
|
||||
TrackId = trackId,
|
||||
ListenedAt = listenedAt ?? DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user