Checkpoint web-player design-system refactor and backend track genre tagging
Advances the ongoing token/componentization cleanup (anchored player dock, border-based panels instead of floating shadows, corrected light-mode accent/gradient tokens) alongside in-progress backend support for track genre tags.
This commit is contained in:
@@ -29,6 +29,7 @@ namespace Musify.Application.Albums
|
||||
var tracksQuery = database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(albumTrack => albumTrack.Track.Owner)
|
||||
.Include(albumTrack => albumTrack.Track.Tags)
|
||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
||||
|
||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||
|
||||
@@ -31,6 +31,8 @@ namespace Musify.Application.Contracts
|
||||
|
||||
DbSet<TrackLike> TrackLikes { get; }
|
||||
|
||||
DbSet<TrackTag> TrackTags { get; }
|
||||
|
||||
DbSet<UserFollow> UserFollows { get; }
|
||||
|
||||
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace Musify.Application.Likes
|
||||
var likesQuery = database.TrackLikes
|
||||
.AsNoTracking()
|
||||
.Include(like => like.Track.Owner)
|
||||
.Include(like => like.Track.Tags)
|
||||
.Where(like => like.UserId == request.UserId);
|
||||
|
||||
var totalCount = await likesQuery.CountAsync(cancellationToken);
|
||||
|
||||
@@ -21,14 +21,20 @@ namespace Musify.Application.Mixes
|
||||
.AsNoTracking()
|
||||
.Include(track => track.Owner)
|
||||
.Where(track => trackIds.Contains(track.Id))
|
||||
.ToDictionaryAsync(track => track.Id, cancellationToken);
|
||||
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count })
|
||||
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
||||
|
||||
return orderedItems
|
||||
.Where(item => tracksById.ContainsKey(item.TrackId))
|
||||
.Select(item =>
|
||||
{
|
||||
var track = tracksById[item.TrackId];
|
||||
return new MixItemApplicationResponse(item.TrackId, track.Title, track.Owner.Name, track.DurationSeconds);
|
||||
var entry = tracksById[item.TrackId];
|
||||
return new MixItemApplicationResponse(
|
||||
item.TrackId,
|
||||
entry.Track.Title,
|
||||
entry.Track.Owner.Name,
|
||||
entry.Track.DurationSeconds,
|
||||
entry.ListensCount);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ namespace Musify.Application.Mixes.Responses
|
||||
Guid TrackId,
|
||||
string Title,
|
||||
string? Artist,
|
||||
double DurationSeconds);
|
||||
double DurationSeconds,
|
||||
int ListensCount);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ namespace Musify.Application.PlayLists
|
||||
var tracksQuery = database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(plt => plt.Track.Owner)
|
||||
.Include(plt => plt.Track.Tags)
|
||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
||||
|
||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||
|
||||
@@ -17,7 +17,8 @@ namespace Musify.Application.Tracks
|
||||
long UserId,
|
||||
string Title,
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId)
|
||||
Guid AudioIntentId,
|
||||
IReadOnlyCollection<Genre> Tags)
|
||||
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
||||
|
||||
public class CreateTrackCommandHandler(
|
||||
@@ -40,6 +41,23 @@ namespace Musify.Application.Tracks
|
||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
var distinctTags = request.Tags.Distinct().ToList();
|
||||
if (distinctTags.Count == 0)
|
||||
{
|
||||
logger.LogWarning("Track {Title} was submitted without any tags", request.Title);
|
||||
return Error.Validation(description: "At least one tag is required.");
|
||||
}
|
||||
|
||||
var tagConflicts = GenreCompatibility.FindConflicts(distinctTags);
|
||||
if (tagConflicts.Count > 0)
|
||||
{
|
||||
var conflict = tagConflicts.First();
|
||||
logger.LogWarning(
|
||||
"Track {Title} was submitted with incompatible tags {First} and {Second}",
|
||||
request.Title, conflict.First, conflict.Second);
|
||||
return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.");
|
||||
}
|
||||
|
||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
uploadIntentConfiguration,
|
||||
request.PictureIntentId, request.UserId, cancellationToken);
|
||||
@@ -80,6 +98,10 @@ namespace Musify.Application.Tracks
|
||||
}
|
||||
};
|
||||
|
||||
trackEntity.Tags = distinctTags
|
||||
.Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag })
|
||||
.ToList();
|
||||
|
||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||
|
||||
await database.UserHasTracks.AddAsync(new UserHasTrack
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace Musify.Application.Tracks
|
||||
|
||||
var tracksById = await database.Tracks
|
||||
.Include(t => t.Owner)
|
||||
.Include(t => t.Tags)
|
||||
.Where(t => recentTrackIds.Contains(t.Id))
|
||||
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count } )
|
||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace Musify.Application.Tracks
|
||||
{
|
||||
var entity = await database.Tracks.AsNoTracking()
|
||||
.Include(t => t.Owner)
|
||||
.Include(t => t.Tags)
|
||||
.Where(t => t.Id == request.TrackId)
|
||||
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace Musify.Application.Tracks
|
||||
var tracksQuery = database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(ut => ut.Track.Owner)
|
||||
.Include(ut => ut.Track.Tags)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Name))
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace Musify.Application.Tracks
|
||||
var tracksQuery = database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(ut => ut.Track.Owner)
|
||||
.Include(ut => ut.Track.Tags)
|
||||
.Where(p => p.UserId == request.UserId);
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Name))
|
||||
|
||||
@@ -14,7 +14,8 @@ namespace Musify.Application.Tracks.Responses
|
||||
int ListensCount,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId)
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
||||
IReadOnlyCollection<Genre> Tags)
|
||||
{
|
||||
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
||||
new(
|
||||
@@ -26,6 +27,7 @@ namespace Musify.Application.Tracks.Responses
|
||||
listensCount,
|
||||
track.CreatedAt,
|
||||
track.UpdatedAt,
|
||||
track.OwnerUserId);
|
||||
track.OwnerUserId,
|
||||
track.Tags.Select(tag => tag.Tag).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,5 +48,7 @@ namespace Musify.Domain.Entities
|
||||
public ICollection<AlbumHasTrack> AlbumTracks { get; set; } = new List<AlbumHasTrack>();
|
||||
|
||||
public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>();
|
||||
|
||||
public ICollection<TrackTag> Tags { get; set; } = new List<TrackTag>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Domain.Entities
|
||||
{
|
||||
#pragma warning disable CS8618
|
||||
public class TrackTag
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required]
|
||||
public required Guid TrackId { get; set; }
|
||||
|
||||
[Required]
|
||||
public required Genre Tag { get; set; }
|
||||
|
||||
[ForeignKey(nameof(TrackId))]
|
||||
public Track Track { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Musify.Domain.ValueObjects
|
||||
{
|
||||
public enum Genre
|
||||
{
|
||||
Pop = 0,
|
||||
Rock = 1,
|
||||
HipHop = 2,
|
||||
RnB = 3,
|
||||
Jazz = 4,
|
||||
Blues = 5,
|
||||
Classical = 6,
|
||||
Electronic = 7,
|
||||
House = 8,
|
||||
Techno = 9,
|
||||
Trance = 10,
|
||||
Dubstep = 11,
|
||||
DrumAndBass = 12,
|
||||
Metal = 13,
|
||||
Punk = 14,
|
||||
Reggae = 15,
|
||||
Reggaeton = 16,
|
||||
Country = 17,
|
||||
Folk = 18,
|
||||
Indie = 19,
|
||||
KPop = 20,
|
||||
Latin = 21,
|
||||
Soul = 22,
|
||||
Funk = 23,
|
||||
Ambient = 24,
|
||||
Lofi = 25
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace Musify.Domain.ValueObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Encodes which genre pairs are mutually exclusive on a single track (e.g. a track cannot
|
||||
/// be tagged as both Classical and Metal at the same time).
|
||||
/// </summary>
|
||||
public static class GenreCompatibility
|
||||
{
|
||||
private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs();
|
||||
|
||||
private static HashSet<(Genre, Genre)> BuildIncompatiblePairs()
|
||||
{
|
||||
var pairs = new HashSet<(Genre, Genre)>();
|
||||
|
||||
void Add(Genre a, Genre b)
|
||||
{
|
||||
pairs.Add((a, b));
|
||||
pairs.Add((b, a));
|
||||
}
|
||||
|
||||
var intense = new[] { Genre.Metal, Genre.Punk, Genre.Dubstep, Genre.DrumAndBass };
|
||||
var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi };
|
||||
|
||||
foreach (var intenseGenre in intense)
|
||||
foreach (var mellowGenre in mellow)
|
||||
Add(intenseGenre, mellowGenre);
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
public static bool AreCompatible(Genre a, Genre b) =>
|
||||
a == b || !IncompatiblePairs.Contains((a, b));
|
||||
|
||||
/// <summary>
|
||||
/// Returns every mutually exclusive genre pair found within the given set of tags.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable<Genre> tags)
|
||||
{
|
||||
var distinctTags = tags.Distinct().ToList();
|
||||
var conflicts = new List<(Genre, Genre)>();
|
||||
|
||||
for (var i = 0; i < distinctTags.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < distinctTags.Count; j++)
|
||||
{
|
||||
if (!AreCompatible(distinctTags[i], distinctTags[j]))
|
||||
conflicts.Add((distinctTags[i], distinctTags[j]));
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence
|
||||
{
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
optionsBuilder.UseNpgsql(configuration.ConnectionString);
|
||||
optionsBuilder.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"));
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
@@ -128,6 +128,16 @@ namespace Musify.Infrastructure.Persistence
|
||||
.HasIndex(like => new { like.UserId, like.TrackId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<TrackTag>()
|
||||
.HasOne(trackTag => trackTag.Track)
|
||||
.WithMany(track => track.Tags)
|
||||
.HasForeignKey(trackTag => trackTag.TrackId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<TrackTag>()
|
||||
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<UserFollow>()
|
||||
.HasOne(follow => follow.Follower)
|
||||
.WithMany()
|
||||
@@ -181,6 +191,8 @@ namespace Musify.Infrastructure.Persistence
|
||||
|
||||
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
|
||||
|
||||
public DbSet<TrackTag> TrackTags => Set<TrackTag>();
|
||||
|
||||
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
|
||||
|
||||
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
|
||||
|
||||
Generated
+1168
File diff suppressed because it is too large
Load Diff
+54
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Musify.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTrackTags : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TrackTags",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TrackId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Tag = table.Column<Genre>(type: "genre", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TrackTags", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TrackTags_Tracks_TrackId",
|
||||
column: x => x.TrackId,
|
||||
principalTable: "Tracks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TrackTags_TrackId_Tag",
|
||||
table: "TrackTags",
|
||||
columns: new[] { "TrackId", "Tag" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TrackTags");
|
||||
|
||||
migrationBuilder.AlterDatabase()
|
||||
.OldAnnotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
@@ -20,6 +21,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" });
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
|
||||
@@ -476,6 +478,26 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("TrackLikes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Genre>("Tag")
|
||||
.HasColumnType("genre");
|
||||
|
||||
b.Property<Guid>("TrackId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrackId", "Tag")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TrackTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1041,6 +1063,17 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b =>
|
||||
{
|
||||
b.HasOne("Musify.Domain.Entities.Track", "Track")
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("TrackId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Track");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
|
||||
{
|
||||
b.HasOne("Musify.Domain.Entities.User", "User")
|
||||
@@ -1113,6 +1146,8 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.Navigation("PlayListTracks");
|
||||
|
||||
b.Navigation("Tags");
|
||||
|
||||
b.Navigation("UserTracks");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Api.DataTransferObjects.Tracks;
|
||||
|
||||
public record CreateTrackRequest(
|
||||
string Title,
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId);
|
||||
Guid AudioIntentId,
|
||||
IReadOnlyCollection<Genre> Tags);
|
||||
|
||||
@@ -147,7 +147,7 @@ public static class TrackEndpoints
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId),
|
||||
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using FluentValidation;
|
||||
using Musify.Api.DataTransferObjects.Tracks;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Api.Validators.Tracks;
|
||||
|
||||
@@ -16,5 +17,18 @@ public sealed class CreateTrackRequestValidator : AbstractValidator<CreateTrackR
|
||||
|
||||
RuleFor(x => x.AudioIntentId)
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.Tags)
|
||||
.NotEmpty()
|
||||
.WithMessage("At least one tag is required.");
|
||||
|
||||
RuleFor(x => x.Tags)
|
||||
.Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0)
|
||||
.When(x => x.Tags is { Count: > 0 })
|
||||
.WithMessage(x =>
|
||||
{
|
||||
var conflict = GenreCompatibility.FindConflicts(x.Tags).First();
|
||||
return $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,16 @@ namespace Musify.Application.Tests.TestSupport
|
||||
.HasIndex(like => new { like.UserId, like.TrackId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<TrackTag>()
|
||||
.HasOne(trackTag => trackTag.Track)
|
||||
.WithMany(track => track.Tags)
|
||||
.HasForeignKey(trackTag => trackTag.TrackId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<TrackTag>()
|
||||
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<UserFollow>()
|
||||
.HasOne(follow => follow.Follower)
|
||||
.WithMany()
|
||||
@@ -138,6 +148,8 @@ namespace Musify.Application.Tests.TestSupport
|
||||
|
||||
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
|
||||
|
||||
public DbSet<TrackTag> TrackTags => Set<TrackTag>();
|
||||
|
||||
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
|
||||
|
||||
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
|
||||
|
||||
@@ -55,8 +55,10 @@ namespace Musify.Application.Tests.TestSupport
|
||||
double durationSeconds = 180,
|
||||
TrackPictures? pictures = null,
|
||||
TrackAudio? audio = null,
|
||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
||||
new()
|
||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
|
||||
IEnumerable<Genre>? tags = null)
|
||||
{
|
||||
var track = new Track
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
@@ -68,6 +70,13 @@ namespace Musify.Application.Tests.TestSupport
|
||||
Audio = audio ?? ProcessedAudio()
|
||||
};
|
||||
|
||||
track.Tags = (tags ?? [Genre.Pop])
|
||||
.Select(tag => new TrackTag { TrackId = track.Id, Tag = tag })
|
||||
.ToList();
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
public static PlayListPictures PlayListPictures() => new()
|
||||
{
|
||||
OriginalName = "original.webp",
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace Musify.Application.Tests.Tracks
|
||||
var (picture, audio) = SeedIntents(user.Id);
|
||||
await SeedAsync(user, picture, audio);
|
||||
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop, Genre.Rock]);
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -50,6 +50,7 @@ namespace Musify.Application.Tests.Tracks
|
||||
Assert.Equal("My Song", result.Value.Title);
|
||||
var localResponse = Assert.IsType<TrackApplicationResponse>(result.Value);
|
||||
Assert.Equal(user.Id, localResponse.OwnerUserId);
|
||||
Assert.Equal([Genre.Pop, Genre.Rock], localResponse.Tags);
|
||||
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
|
||||
|
||||
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
|
||||
@@ -57,10 +58,38 @@ namespace Musify.Application.Tests.Tracks
|
||||
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoTags_ReturnsValidationError()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var (picture, audio) = SeedIntents(user.Id);
|
||||
await SeedAsync(user, picture, audio);
|
||||
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, []);
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_IncompatibleTags_ReturnsValidationError()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var (picture, audio) = SeedIntents(user.Id);
|
||||
await SeedAsync(user, picture, audio);
|
||||
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Classical, Genre.Metal]);
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||
{
|
||||
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid());
|
||||
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop]);
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -75,7 +104,7 @@ namespace Musify.Application.Tests.Tracks
|
||||
var (picture, audio) = SeedIntents(otherUser.Id);
|
||||
await SeedAsync(user, otherUser, picture, audio);
|
||||
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]);
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -91,7 +120,7 @@ namespace Musify.Application.Tests.Tracks
|
||||
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed);
|
||||
await SeedAsync(user, picture, audio);
|
||||
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]);
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Domain.Tests.ValueObjects
|
||||
{
|
||||
public sealed class GenreCompatibilityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Genre.Classical, Genre.Metal)]
|
||||
[InlineData(Genre.Classical, Genre.Punk)]
|
||||
[InlineData(Genre.Ambient, Genre.Dubstep)]
|
||||
[InlineData(Genre.Lofi, Genre.DrumAndBass)]
|
||||
public void AreCompatible_IncompatiblePair_ReturnsFalse(Genre first, Genre second)
|
||||
{
|
||||
Assert.False(GenreCompatibility.AreCompatible(first, second));
|
||||
Assert.False(GenreCompatibility.AreCompatible(second, first));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Genre.Pop, Genre.Rock)]
|
||||
[InlineData(Genre.HipHop, Genre.RnB)]
|
||||
[InlineData(Genre.Classical, Genre.Jazz)]
|
||||
public void AreCompatible_CompatiblePair_ReturnsTrue(Genre first, Genre second)
|
||||
{
|
||||
Assert.True(GenreCompatibility.AreCompatible(first, second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AreCompatible_SameGenre_ReturnsTrue()
|
||||
{
|
||||
Assert.True(GenreCompatibility.AreCompatible(Genre.Pop, Genre.Pop));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindConflicts_NoIncompatiblePairs_ReturnsEmpty()
|
||||
{
|
||||
var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Rock, Genre.Indie]);
|
||||
|
||||
Assert.Empty(conflicts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindConflicts_IncompatiblePairPresent_ReturnsIt()
|
||||
{
|
||||
var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Classical, Genre.Metal]);
|
||||
|
||||
var conflict = Assert.Single(conflicts);
|
||||
Assert.Equal((Genre.Classical, Genre.Metal), conflict);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,31 @@ namespace Musify.Infrastructure.Tests.Persistence
|
||||
Assert.Equal(user.Id, stored.OwnerUserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum()
|
||||
{
|
||||
await using var database = fixture.CreateDatabase();
|
||||
|
||||
var userName = "tags-roundtrip-user";
|
||||
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() };
|
||||
var track = BuildTrack(user, $"tags-{Guid.NewGuid():N}");
|
||||
track.Tags =
|
||||
[
|
||||
new TrackTag { TrackId = track.Id, Tag = Genre.Pop },
|
||||
new TrackTag { TrackId = track.Id, Tag = Genre.Rock }
|
||||
];
|
||||
|
||||
await database.AddRangeAsync(user, track);
|
||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var reloaded = fixture.CreateDatabase();
|
||||
var stored = await reloaded.Tracks.AsNoTracking()
|
||||
.Include(t => t.Tags)
|
||||
.SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal([Genre.Pop, Genre.Rock], stored.Tags.Select(t => t.Tag).OrderBy(t => t));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user