Close data leaks: remove anonymous user creation, hide private playlists, validate uploads and clamp gateway ranges
This commit is contained in:
@@ -43,7 +43,7 @@ public class CreateAlbumCommandHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
|
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
|
uploadIntentConfiguration, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, cancellationToken);
|
||||||
if (pictureIntent.IsError)
|
if (pictureIntent.IsError)
|
||||||
return pictureIntent.Errors;
|
return pictureIntent.Errors;
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public class UpdateAlbumCommandHandler(
|
|||||||
{
|
{
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
uploadIntentConfiguration,
|
uploadIntentConfiguration,
|
||||||
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
|
request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.AlbumPicture, cancellationToken);
|
||||||
if (validation.IsError)
|
if (validation.IsError)
|
||||||
return validation.Errors;
|
return validation.Errors;
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ public class CreatePlayListCommandHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken);
|
uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, cancellationToken);
|
||||||
|
|
||||||
if (validation.IsError)
|
if (validation.IsError)
|
||||||
return validation.Errors;
|
return validation.Errors;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
using X.PagedList;
|
using X.PagedList;
|
||||||
using X.PagedList.EF;
|
using X.PagedList.EF;
|
||||||
|
|
||||||
@@ -12,7 +13,8 @@ namespace Musify.Application.PlayLists;
|
|||||||
public record GetPlayListTracksQuery(
|
public record GetPlayListTracksQuery(
|
||||||
Guid PlayListId,
|
Guid PlayListId,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize)
|
int PageSize,
|
||||||
|
long? ViewerId = null)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
||||||
|
|
||||||
public class GetPlayListTracksQueryHandler(IDatabase database)
|
public class GetPlayListTracksQueryHandler(IDatabase database)
|
||||||
@@ -22,7 +24,10 @@ public class GetPlayListTracksQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
var playListExists = await database.PlayLists
|
var playListExists = await database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(p => p.Id == request.PlayListId, cancellationToken);
|
.AnyAsync(
|
||||||
|
p => p.Id == request.PlayListId
|
||||||
|
&& (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId),
|
||||||
|
cancellationToken);
|
||||||
if (!playListExists)
|
if (!playListExists)
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists;
|
|
||||||
|
|
||||||
public record GetPlayListsQuery(int PageNumber, int PageSize)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetPlayListsQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await database.PlayLists
|
|
||||||
.AsNoTracking()
|
|
||||||
.OrderBy(p => p.Id)
|
|
||||||
.Select(p => new
|
|
||||||
{
|
|
||||||
PlayList = p,
|
|
||||||
CoverTrackIds = p.PlayListTracks
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
|
||||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@ public record GetPlayListsByUserIdQuery(
|
|||||||
string? Name,
|
string? Name,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize,
|
int PageSize,
|
||||||
bool OnlyPublic = false)
|
long? ViewerId = null)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
|
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
|
||||||
|
|
||||||
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
||||||
@@ -25,12 +25,8 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
var playListsQuery = database.PlayLists
|
var playListsQuery = database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(p => p.UserId == request.UserId);
|
.Where(p => p.UserId == request.UserId
|
||||||
|
&& (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId));
|
||||||
if (request.OnlyPublic)
|
|
||||||
{
|
|
||||||
playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrEmpty(request.Name))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ public class UpdatePlayListCommandHandler(
|
|||||||
{
|
{
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
uploadIntentConfiguration,
|
uploadIntentConfiguration,
|
||||||
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
|
request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, cancellationToken);
|
||||||
if (validation.IsError)
|
if (validation.IsError)
|
||||||
return validation.Errors;
|
return validation.Errors;
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public sealed class UploadIntentValidator(
|
|||||||
UploadIntentConfiguration config,
|
UploadIntentConfiguration config,
|
||||||
Guid intentId,
|
Guid intentId,
|
||||||
long userId,
|
long userId,
|
||||||
|
UploadIntentPurpose purpose,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var intent = await database.UploadIntents
|
var intent = await database.UploadIntents
|
||||||
@@ -45,6 +46,9 @@ public sealed class UploadIntentValidator(
|
|||||||
if (intent == null || intent.UserId != userId)
|
if (intent == null || intent.UserId != userId)
|
||||||
return Error.NotFound(description: "Upload intent not found or not accessible.");
|
return Error.NotFound(description: "Upload intent not found or not accessible.");
|
||||||
|
|
||||||
|
if (intent.Purpose != purpose)
|
||||||
|
return Error.Validation(description: "Upload intent was issued for a different purpose.");
|
||||||
|
|
||||||
if (intent.IsConsumed)
|
if (intent.IsConsumed)
|
||||||
return Error.Conflict(description: "Upload intent has already been consumed.");
|
return Error.Conflict(description: "Upload intent has already been consumed.");
|
||||||
|
|
||||||
|
|||||||
@@ -59,15 +59,18 @@ public class CreateTrackCommandHandler(
|
|||||||
return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.");
|
return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.PictureIntentId == request.AudioIntentId)
|
||||||
|
return Error.Validation(description: "Picture and audio must use different upload intents.");
|
||||||
|
|
||||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
uploadIntentConfiguration,
|
uploadIntentConfiguration,
|
||||||
request.PictureIntentId, request.UserId, cancellationToken);
|
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
||||||
if (pictureValidation.IsError)
|
if (pictureValidation.IsError)
|
||||||
return pictureValidation.Errors;
|
return pictureValidation.Errors;
|
||||||
|
|
||||||
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
uploadIntentConfiguration,
|
uploadIntentConfiguration,
|
||||||
request.AudioIntentId, request.UserId, cancellationToken);
|
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken);
|
||||||
if (audioValidation.IsError)
|
if (audioValidation.IsError)
|
||||||
return audioValidation.Errors;
|
return audioValidation.Errors;
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Users.Responses;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users;
|
|
||||||
|
|
||||||
public record CreateUserCommand(long Id,
|
|
||||||
string Name,
|
|
||||||
string? FirstName,
|
|
||||||
string? SecondName)
|
|
||||||
: ICommand<ErrorOr<UserApplicationResponse>>
|
|
||||||
{
|
|
||||||
public static User ToEntity(CreateUserCommand command)
|
|
||||||
{
|
|
||||||
return new User
|
|
||||||
{
|
|
||||||
Id = command.Id,
|
|
||||||
Name = command.Name,
|
|
||||||
NormalizedName = command.Name.ToUpperInvariant(),
|
|
||||||
FirstName = command.FirstName,
|
|
||||||
SecondName = command.SecondName
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CreateUserCommandHandler(IDatabase database, ILogger<CreateUserCommandHandler> logger)
|
|
||||||
: ICommandHandler<CreateUserCommand, ErrorOr<UserApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<UserApplicationResponse>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var userExists = await database.Users.AnyAsync(u => u.Id == request.Id, cancellationToken);
|
|
||||||
if (userExists)
|
|
||||||
{
|
|
||||||
logger.LogInformation("User {UserId} already exists", request.Id);
|
|
||||||
return Error.Conflict(description: $"User {request.Id} already exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
var newUser = CreateUserCommand.ToEntity(request);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.Users.AddAsync(newUser, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to create user {UserId}", request.Id);
|
|
||||||
return Error.Failure(description: $"Failed to create user {request.Id}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Created user {UserId}", newUser.Id);
|
|
||||||
return UserApplicationResponse.FromEntity(newUser);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Musify.Api.DataTransferObjects.Users;
|
|
||||||
|
|
||||||
public record CreateUserRequest(
|
|
||||||
long Id,
|
|
||||||
string Name,
|
|
||||||
string? FirstName,
|
|
||||||
string? SecondName);
|
|
||||||
@@ -19,11 +19,6 @@ public static class PlayListEndpoints
|
|||||||
var group = app.MapGroup("/playlists")
|
var group = app.MapGroup("/playlists")
|
||||||
.WithTags("PlayLists");
|
.WithTags("PlayLists");
|
||||||
|
|
||||||
group.MapGet("/", GetPlayLists)
|
|
||||||
.WithName("GetPlayLists")
|
|
||||||
.WithSummary("Get Paginated PlayLists.")
|
|
||||||
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
|
|
||||||
|
|
||||||
group.MapGet("/{id}", GetPlayListById)
|
group.MapGet("/{id}", GetPlayListById)
|
||||||
.WithName("GetPlayListById")
|
.WithName("GetPlayListById")
|
||||||
.WithSummary("Get A PlayList By Id.")
|
.WithSummary("Get A PlayList By Id.")
|
||||||
@@ -32,7 +27,7 @@ public static class PlayListEndpoints
|
|||||||
|
|
||||||
group.MapGet("/users/{userId}", GetPlayListsByUserId)
|
group.MapGet("/users/{userId}", GetPlayListsByUserId)
|
||||||
.WithName("GetPlayListsByUserId")
|
.WithName("GetPlayListsByUserId")
|
||||||
.WithSummary("Get Paginated PlayLists For A User.")
|
.WithSummary("Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner.")
|
||||||
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
|
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
|
||||||
|
|
||||||
group.MapGet("/{playlistId}/tracks", GetPlayListTracks)
|
group.MapGet("/{playlistId}/tracks", GetPlayListTracks)
|
||||||
@@ -105,16 +100,6 @@ public static class PlayListEndpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetPlayLists(
|
|
||||||
IMediator mediator,
|
|
||||||
CancellationToken cancellationToken,
|
|
||||||
int pageNumber = 1,
|
|
||||||
int pageSize = 10)
|
|
||||||
{
|
|
||||||
var result = await mediator.Send(new GetPlayListsQuery(pageNumber, pageSize), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> GetPlayListById(
|
private static async Task<IResult> GetPlayListById(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
CurrentUser currentUser,
|
CurrentUser currentUser,
|
||||||
@@ -127,25 +112,26 @@ public static class PlayListEndpoints
|
|||||||
|
|
||||||
private static async Task<IResult> GetPlayListsByUserId(
|
private static async Task<IResult> GetPlayListsByUserId(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
|
CurrentUser currentUser,
|
||||||
long userId,
|
long userId,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
string? name,
|
string? name,
|
||||||
int pageNumber = 1,
|
int pageNumber = 1,
|
||||||
int pageSize = 10,
|
int pageSize = 10)
|
||||||
bool onlyPublic = false)
|
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, onlyPublic), cancellationToken);
|
var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, currentUser.Id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetPlayListTracks(
|
private static async Task<IResult> GetPlayListTracks(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
|
CurrentUser currentUser,
|
||||||
Guid playlistId,
|
Guid playlistId,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
int pageNumber = 1,
|
int pageNumber = 1,
|
||||||
int pageSize = 10)
|
int pageSize = 10)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize), cancellationToken);
|
var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize, currentUser.Id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Musify.Api.Authentication;
|
using Musify.Api.Authentication;
|
||||||
using Musify.Api.DataTransferObjects.Users;
|
|
||||||
using Musify.Api.Extensions;
|
using Musify.Api.Extensions;
|
||||||
using Musify.Api.Filters;
|
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks;
|
using Musify.Application.Tracks;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
@@ -38,7 +36,10 @@ public static class UserEndpoints
|
|||||||
group.MapGet("/{id}/listening-stats", GetListeningStats)
|
group.MapGet("/{id}/listening-stats", GetListeningStats)
|
||||||
.WithName("GetListeningStats")
|
.WithName("GetListeningStats")
|
||||||
.WithSummary("Get A User'S Listening Stats For The Current Week And Streak.")
|
.WithSummary("Get A User'S Listening Stats For The Current Week And Streak.")
|
||||||
.Produces<ListeningStatsResponse>();
|
.RequireAuthorization()
|
||||||
|
.Produces<ListeningStatsResponse>()
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
group.MapGet("/{id}/last-listened-track", GetLastTrackListenedByUserId)
|
group.MapGet("/{id}/last-listened-track", GetLastTrackListenedByUserId)
|
||||||
.WithName("GetLastTrackListenedByUserId")
|
.WithName("GetLastTrackListenedByUserId")
|
||||||
@@ -88,14 +89,6 @@ public static class UserEndpoints
|
|||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces(StatusCodes.Status401Unauthorized);
|
.Produces(StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
group.MapPost("/", CreateUser)
|
|
||||||
.WithName("CreateUser")
|
|
||||||
.WithSummary("Create A New User.")
|
|
||||||
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>()
|
|
||||||
.Produces<UserApplicationResponse>(StatusCodes.Status201Created)
|
|
||||||
.ProducesValidationProblem()
|
|
||||||
.Produces(StatusCodes.Status409Conflict);
|
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,12 +121,16 @@ public static class UserEndpoints
|
|||||||
return await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken);
|
return await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<ListeningStatsResponse> GetListeningStats(
|
private static async Task<IResult> GetListeningStats(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
|
CurrentUser currentUser,
|
||||||
long id,
|
long id,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await mediator.Send(new GetListeningStatsQuery(id), cancellationToken);
|
if (currentUser.RequiredId != id)
|
||||||
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
return Results.Ok(await mediator.Send(new GetListeningStatsQuery(id), cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetLastTrackListenedByUserId(
|
private static async Task<IResult> GetLastTrackListenedByUserId(
|
||||||
@@ -207,16 +204,4 @@ public static class UserEndpoints
|
|||||||
var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken);
|
var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CreateUser(
|
|
||||||
IMediator mediator,
|
|
||||||
CreateUserRequest request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var result = await mediator.Send(
|
|
||||||
new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return result.ToCreatedResult(user => $"/users/{user.Id}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -1,20 +1,20 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Musify.Api.DataTransferObjects.Albums;
|
using Musify.Api.DataTransferObjects.Albums;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
|
||||||
namespace Musify.Api.Validators.Albums;
|
namespace Musify.Api.Validators.Albums;
|
||||||
|
|
||||||
public sealed class RequestAlbumPictureUploadRequestValidator : AbstractValidator<RequestAlbumPictureUploadRequest>
|
public sealed class RequestAlbumPictureUploadRequestValidator : AbstractValidator<RequestAlbumPictureUploadRequest>
|
||||||
{
|
{
|
||||||
public RequestAlbumPictureUploadRequestValidator()
|
public RequestAlbumPictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration)
|
||||||
{
|
{
|
||||||
RuleFor(x => x.FileType)
|
RuleFor(x => x.FileType)
|
||||||
.NotEmpty();
|
.MustBeFileType(Uploads.PictureFileTypes);
|
||||||
|
|
||||||
RuleFor(x => x.ContentType)
|
RuleFor(x => x.ContentType)
|
||||||
.NotEmpty();
|
.MustBeContentType(Uploads.PictureContentTypes);
|
||||||
|
|
||||||
RuleFor(x => x.ExpectedSizeBytes)
|
RuleFor(x => x.ExpectedSizeBytes)
|
||||||
.GreaterThan(0)
|
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||||
.When(x => x.ExpectedSizeBytes.HasValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -1,20 +1,20 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Musify.Api.DataTransferObjects.PlayLists;
|
using Musify.Api.DataTransferObjects.PlayLists;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
|
||||||
namespace Musify.Api.Validators.PlayLists;
|
namespace Musify.Api.Validators.PlayLists;
|
||||||
|
|
||||||
public sealed class RequestPlayListPictureUploadRequestValidator : AbstractValidator<RequestPlayListPictureUploadRequest>
|
public sealed class RequestPlayListPictureUploadRequestValidator : AbstractValidator<RequestPlayListPictureUploadRequest>
|
||||||
{
|
{
|
||||||
public RequestPlayListPictureUploadRequestValidator()
|
public RequestPlayListPictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration)
|
||||||
{
|
{
|
||||||
RuleFor(x => x.FileType)
|
RuleFor(x => x.FileType)
|
||||||
.NotEmpty();
|
.MustBeFileType(Uploads.PictureFileTypes);
|
||||||
|
|
||||||
RuleFor(x => x.ContentType)
|
RuleFor(x => x.ContentType)
|
||||||
.NotEmpty();
|
.MustBeContentType(Uploads.PictureContentTypes);
|
||||||
|
|
||||||
RuleFor(x => x.ExpectedSizeBytes)
|
RuleFor(x => x.ExpectedSizeBytes)
|
||||||
.GreaterThan(0)
|
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||||
.When(x => x.ExpectedSizeBytes.HasValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-9
@@ -1,30 +1,29 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Musify.Api.DataTransferObjects.Tracks;
|
using Musify.Api.DataTransferObjects.Tracks;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
|
||||||
namespace Musify.Api.Validators.Tracks;
|
namespace Musify.Api.Validators.Tracks;
|
||||||
|
|
||||||
public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator<RequestTrackUploadUrlsRequest>
|
public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator<RequestTrackUploadUrlsRequest>
|
||||||
{
|
{
|
||||||
public RequestTrackUploadUrlsRequestValidator()
|
public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration)
|
||||||
{
|
{
|
||||||
RuleFor(x => x.PictureFileType)
|
RuleFor(x => x.PictureFileType)
|
||||||
.NotEmpty();
|
.MustBeFileType(Uploads.PictureFileTypes);
|
||||||
|
|
||||||
RuleFor(x => x.PictureContentType)
|
RuleFor(x => x.PictureContentType)
|
||||||
.NotEmpty();
|
.MustBeContentType(Uploads.PictureContentTypes);
|
||||||
|
|
||||||
RuleFor(x => x.AudioFileType)
|
RuleFor(x => x.AudioFileType)
|
||||||
.NotEmpty();
|
.MustBeFileType(Uploads.AudioFileTypes);
|
||||||
|
|
||||||
RuleFor(x => x.AudioContentType)
|
RuleFor(x => x.AudioContentType)
|
||||||
.NotEmpty();
|
.MustBeContentType(Uploads.AudioContentTypes);
|
||||||
|
|
||||||
RuleFor(x => x.ExpectedPictureSizeBytes)
|
RuleFor(x => x.ExpectedPictureSizeBytes)
|
||||||
.GreaterThan(0)
|
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||||
.When(x => x.ExpectedPictureSizeBytes.HasValue);
|
|
||||||
|
|
||||||
RuleFor(x => x.ExpectedAudioSizeBytes)
|
RuleFor(x => x.ExpectedAudioSizeBytes)
|
||||||
.GreaterThan(0)
|
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||||
.When(x => x.ExpectedAudioSizeBytes.HasValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Musify.Api.Validators;
|
||||||
|
|
||||||
|
public static class Uploads
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlySet<string> PictureFileTypes =
|
||||||
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "jpg", "jpeg", "png", "webp" };
|
||||||
|
|
||||||
|
public static readonly IReadOnlySet<string> PictureContentTypes =
|
||||||
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp" };
|
||||||
|
|
||||||
|
public static readonly IReadOnlySet<string> AudioFileTypes =
|
||||||
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "mp3", "wav", "flac", "ogg", "m4a", "aac" };
|
||||||
|
|
||||||
|
public static readonly IReadOnlySet<string> AudioContentTypes =
|
||||||
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/wave", "audio/flac", "audio/x-flac",
|
||||||
|
"audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static IRuleBuilderOptions<T, string> MustBeFileType<T>(
|
||||||
|
this IRuleBuilder<T, string> rule, IReadOnlySet<string> allowed) =>
|
||||||
|
rule
|
||||||
|
.NotEmpty()
|
||||||
|
.Length(1, 10)
|
||||||
|
.Matches("^[A-Za-z0-9]+$").WithMessage("'{PropertyName}' must contain only letters and digits.")
|
||||||
|
.Must(allowed.Contains).WithMessage("'{PropertyName}' must be one of: " + string.Join(", ", allowed) + ".");
|
||||||
|
|
||||||
|
public static IRuleBuilderOptions<T, string> MustBeContentType<T>(
|
||||||
|
this IRuleBuilder<T, string> rule, IReadOnlySet<string> allowed) =>
|
||||||
|
rule
|
||||||
|
.NotEmpty()
|
||||||
|
.Must(allowed.Contains).WithMessage("'{PropertyName}' is not an allowed content type.");
|
||||||
|
|
||||||
|
public static IRuleBuilderOptions<T, long?> MustBeValidUploadSize<T>(
|
||||||
|
this IRuleBuilder<T, long?> rule, long maxUploadBytes) =>
|
||||||
|
rule
|
||||||
|
.Must(size => size is null || (size > 0 && size <= maxUploadBytes))
|
||||||
|
.WithMessage($"'{{PropertyName}}' must be greater than 0 and at most {maxUploadBytes} bytes.");
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
using Musify.Api.DataTransferObjects.Users;
|
|
||||||
|
|
||||||
namespace Musify.Api.Validators.Users;
|
|
||||||
|
|
||||||
public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
|
|
||||||
{
|
|
||||||
public CreateUserRequestValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Id)
|
|
||||||
.NotEmpty();
|
|
||||||
|
|
||||||
RuleFor(x => x.Name)
|
|
||||||
.NotEmpty()
|
|
||||||
.MaximumLength(48);
|
|
||||||
|
|
||||||
RuleFor(x => x.FirstName)
|
|
||||||
.MaximumLength(48)
|
|
||||||
.When(x => !string.IsNullOrWhiteSpace(x.FirstName));
|
|
||||||
|
|
||||||
RuleFor(x => x.SecondName)
|
|
||||||
.MaximumLength(48)
|
|
||||||
.When(x => !string.IsNullOrWhiteSpace(x.SecondName));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -61,7 +61,7 @@ public sealed class TicketValidationMiddleware(
|
|||||||
if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes))
|
if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes))
|
||||||
{
|
{
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Rejected media request {ObjectKey}: requested range starts beyond the {MaxBytes}-byte preview limit",
|
"Rejected media request {ObjectKey}: requested range is a suffix range or starts beyond the {MaxBytes}-byte preview limit",
|
||||||
objectKey, maxBytes);
|
objectKey, maxBytes);
|
||||||
context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable;
|
context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable;
|
||||||
context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}";
|
context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}";
|
||||||
@@ -81,11 +81,19 @@ public sealed class TicketValidationMiddleware(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) || range.Ranges.Count != 1)
|
if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range)
|
||||||
|
|| range.Ranges.Count != 1
|
||||||
|
|| !string.Equals(range.Unit.ToString(), "bytes", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}";
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
var requested = range.Ranges.Single();
|
var requested = range.Ranges.Single();
|
||||||
var start = requested.From ?? 0;
|
if (requested.From is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var start = requested.From.Value;
|
||||||
if (start >= maxBytes)
|
if (start >= maxBytes)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using Musify.Api.DataTransferObjects.Albums;
|
using Musify.Api.DataTransferObjects.Albums;
|
||||||
using Musify.Api.DataTransferObjects.Users;
|
|
||||||
using Musify.Api.Tests.TestSupport;
|
using Musify.Api.Tests.TestSupport;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
@@ -13,14 +12,7 @@ namespace Musify.Api.Tests.Endpoints;
|
|||||||
[Collection(ApiCollection.Name)]
|
[Collection(ApiCollection.Name)]
|
||||||
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
|
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
|
||||||
{
|
{
|
||||||
private async Task<long> CreateUserAsync()
|
private Task<long> CreateUserAsync() => fixture.SeedUserAsync();
|
||||||
{
|
|
||||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
|
||||||
var response = await fixture.CreateAnonymousClient()
|
|
||||||
.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null));
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
return userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Guid> CreatePictureIntentAsync(HttpClient client)
|
private async Task<Guid> CreatePictureIntentAsync(HttpClient client)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using System.Net.Http.Json;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Musify.Api.DataTransferObjects.Tracks;
|
using Musify.Api.DataTransferObjects.Tracks;
|
||||||
using Musify.Api.DataTransferObjects.Users;
|
|
||||||
using Musify.Api.Tests.TestSupport;
|
using Musify.Api.Tests.TestSupport;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Infrastructure.Persistence;
|
using Musify.Infrastructure.Persistence;
|
||||||
@@ -14,14 +13,7 @@ namespace Musify.Api.Tests.Endpoints;
|
|||||||
[Collection(ApiCollection.Name)]
|
[Collection(ApiCollection.Name)]
|
||||||
public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture)
|
public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture)
|
||||||
{
|
{
|
||||||
private async Task<long> CreateUserAsync()
|
private Task<long> CreateUserAsync() => fixture.SeedUserAsync();
|
||||||
{
|
|
||||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
|
||||||
var response = await fixture.CreateAnonymousClient().PostAsJsonAsync(
|
|
||||||
"/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
return userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Guid> SeedListenAsync(long userId, double durationSeconds)
|
private async Task<Guid> SeedListenAsync(long userId, double durationSeconds)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Musify.Api.DataTransferObjects.Users;
|
|
||||||
using Musify.Api.Tests.TestSupport;
|
using Musify.Api.Tests.TestSupport;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Application.Users.Responses;
|
using Musify.Application.Users.Responses;
|
||||||
@@ -45,40 +44,13 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture)
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader()
|
public async Task PostUsers_IsNotExposed()
|
||||||
{
|
|
||||||
var client = fixture.CreateAnonymousClient();
|
|
||||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
|
||||||
Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString);
|
|
||||||
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken);
|
|
||||||
Assert.NotNull(body);
|
|
||||||
Assert.Equal("Jane Doe", body.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task PostUsers_BlankName_ReturnsValidationProblem()
|
|
||||||
{
|
{
|
||||||
var client = fixture.CreateAnonymousClient();
|
var client = fixture.CreateAnonymousClient();
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken);
|
var response = await client.PostAsJsonAsync("/users", new { id = 1, name = "Intruder" }, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task PostUsers_DuplicateId_ReturnsConflict()
|
|
||||||
{
|
|
||||||
var client = fixture.CreateAnonymousClient();
|
|
||||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
|
||||||
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -86,7 +58,7 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture)
|
|||||||
{
|
{
|
||||||
var client = fixture.CreateAnonymousClient();
|
var client = fixture.CreateAnonymousClient();
|
||||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||||
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken);
|
await fixture.SeedUserAsync(userId, "Findable User");
|
||||||
|
|
||||||
var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken);
|
var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
@@ -110,7 +82,7 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture)
|
|||||||
{
|
{
|
||||||
var client = fixture.CreateAnonymousClient();
|
var client = fixture.CreateAnonymousClient();
|
||||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||||
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
|
await fixture.SeedUserAsync(userId);
|
||||||
await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10));
|
await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10));
|
||||||
await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow);
|
await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow);
|
||||||
|
|
||||||
@@ -126,7 +98,7 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture)
|
|||||||
{
|
{
|
||||||
var client = fixture.CreateAnonymousClient();
|
var client = fixture.CreateAnonymousClient();
|
||||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||||
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
|
await fixture.SeedUserAsync(userId);
|
||||||
|
|
||||||
var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken);
|
var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,23 @@ public sealed class ApiTestFixture : WebApplicationFactory<Program>, IAsyncLifet
|
|||||||
|
|
||||||
public HttpClient CreateAnonymousClient() => CreateClient();
|
public HttpClient CreateAnonymousClient() => CreateClient();
|
||||||
|
|
||||||
|
public async Task<long> SeedUserAsync(long? userId = null, string? name = null)
|
||||||
|
{
|
||||||
|
var id = userId ?? Random.Shared.NextInt64(1, long.MaxValue);
|
||||||
|
var userName = name ?? $"user-{id}";
|
||||||
|
|
||||||
|
using var scope = Services.CreateScope();
|
||||||
|
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
|
||||||
|
database.Users.Add(new Musify.Domain.Entities.User
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = userName,
|
||||||
|
NormalizedName = userName.ToUpperInvariant()
|
||||||
|
});
|
||||||
|
await database.SaveChangesAsync();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user")
|
public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user")
|
||||||
{
|
{
|
||||||
var client = CreateClient();
|
var client = CreateClient();
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
using Musify.Api.DataTransferObjects.Users;
|
|
||||||
using Musify.Api.Validators.Users;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Musify.Api.Tests.Validators;
|
|
||||||
|
|
||||||
public sealed class CreateUserRequestValidatorTests
|
|
||||||
{
|
|
||||||
private readonly CreateUserRequestValidator validator = new();
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_ValidRequest_Passes()
|
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe"));
|
|
||||||
|
|
||||||
Assert.True(result.IsValid);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_ZeroId_Fails()
|
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null));
|
|
||||||
|
|
||||||
Assert.False(result.IsValid);
|
|
||||||
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_BlankName_Fails()
|
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateUserRequest(1, "", null, null));
|
|
||||||
|
|
||||||
Assert.False(result.IsValid);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_NameOverMaxLength_Fails()
|
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null));
|
|
||||||
|
|
||||||
Assert.False(result.IsValid);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_OptionalNamesOmitted_Passes()
|
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null));
|
|
||||||
|
|
||||||
Assert.True(result.IsValid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Musify.Api.DataTransferObjects.Albums;
|
||||||
|
using Musify.Api.DataTransferObjects.Tracks;
|
||||||
|
using Musify.Api.Validators.Albums;
|
||||||
|
using Musify.Api.Validators.Tracks;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Api.Tests.Validators;
|
||||||
|
|
||||||
|
public sealed class UploadRequestValidatorTests
|
||||||
|
{
|
||||||
|
private readonly UploadIntentConfiguration configuration = new() { MaxUploadBytes = 1_000 };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PictureUpload_ValidRequest_Passes()
|
||||||
|
{
|
||||||
|
var validator = new RequestAlbumPictureUploadRequestValidator(configuration);
|
||||||
|
|
||||||
|
var result = validator.Validate(new RequestAlbumPictureUploadRequest("webp", "image/webp", 500));
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("exe", "image/webp", 500L)]
|
||||||
|
[InlineData("averyveryveryloongextension", "image/webp", 500L)]
|
||||||
|
[InlineData("we bp", "image/webp", 500L)]
|
||||||
|
[InlineData("webp", "text/html", 500L)]
|
||||||
|
[InlineData("webp", "image/webp", 0L)]
|
||||||
|
[InlineData("webp", "image/webp", 1_001L)]
|
||||||
|
public void PictureUpload_InvalidRequest_Fails(string fileType, string contentType, long size)
|
||||||
|
{
|
||||||
|
var validator = new RequestAlbumPictureUploadRequestValidator(configuration);
|
||||||
|
|
||||||
|
var result = validator.Validate(new RequestAlbumPictureUploadRequest(fileType, contentType, size));
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TrackUpload_AudioTypesAreCheckedAgainstTheAudioAllowList()
|
||||||
|
{
|
||||||
|
var validator = new RequestTrackUploadUrlsRequestValidator(configuration);
|
||||||
|
|
||||||
|
Assert.True(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", 10, 900)).IsValid);
|
||||||
|
Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "png", "image/png")).IsValid);
|
||||||
|
Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("mp3", "audio/mpeg", "mp3", "audio/mpeg")).IsValid);
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-1
@@ -2,6 +2,7 @@ using ErrorOr;
|
|||||||
using Musify.Application.PlayLists;
|
using Musify.Application.PlayLists;
|
||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists;
|
||||||
@@ -22,7 +23,7 @@ public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase
|
|||||||
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
|
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
|
||||||
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 });
|
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 });
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
Assert.False(result.IsError);
|
||||||
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
|
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
|
||||||
@@ -35,4 +36,28 @@ public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PrivatePlayListOfAnotherUser_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id, visibility: PlaylistVisibility.Private);
|
||||||
|
await SeedAsync(owner, playList);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PrivatePlayListOfViewer_ReturnsTracks()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id, visibility: PlaylistVisibility.Private);
|
||||||
|
await SeedAsync(owner, playList);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-4
@@ -20,7 +20,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
|
|||||||
TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing),
|
TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing),
|
||||||
TestEntities.PlayList(other.Id, "Theirs"));
|
TestEntities.PlayList(other.Id, "Theirs"));
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
Assert.False(result.IsError);
|
||||||
var playList = Assert.Single(result.Value.Items);
|
var playList = Assert.Single(result.Value.Items);
|
||||||
@@ -28,7 +28,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_OnlyPublic_ReturnsOnlyPublicPlayLists()
|
public async Task Handle_AnotherViewer_ReturnsOnlyPublicPlayLists()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
var owner = TestEntities.User();
|
||||||
await SeedAsync(
|
await SeedAsync(
|
||||||
@@ -36,20 +36,35 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
|
|||||||
TestEntities.PlayList(owner.Id, "Public one", visibility: PlaylistVisibility.Public),
|
TestEntities.PlayList(owner.Id, "Public one", visibility: PlaylistVisibility.Public),
|
||||||
TestEntities.PlayList(owner.Id, "Private one", visibility: PlaylistVisibility.Private));
|
TestEntities.PlayList(owner.Id, "Private one", visibility: PlaylistVisibility.Private));
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, OnlyPublic: true), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
Assert.False(result.IsError);
|
||||||
var playList = Assert.Single(result.Value.Items);
|
var playList = Assert.Single(result.Value.Items);
|
||||||
Assert.Equal("Public one", playList.Name);
|
Assert.Equal("Public one", playList.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Owner_SeesPrivatePlayLists()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
await SeedAsync(
|
||||||
|
owner,
|
||||||
|
TestEntities.PlayList(owner.Id, "Public one", visibility: PlaylistVisibility.Public),
|
||||||
|
TestEntities.PlayList(owner.Id, "Private one", visibility: PlaylistVisibility.Private));
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(2, result.Value.Items.Count);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists()
|
public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
var owner = TestEntities.User();
|
||||||
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus"));
|
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus"));
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
Assert.False(result.IsError);
|
||||||
var playList = Assert.Single(result.Value.Items);
|
var playList = Assert.Single(result.Value.Items);
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
using Musify.Application.PlayLists;
|
|
||||||
using Musify.Application.Tests.TestSupport;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
|
||||||
|
|
||||||
public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
|
||||||
private GetPlayListsQueryHandler CreateHandler() => new(Database);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_ReturnsEveryPlayListPaged()
|
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "One"), TestEntities.PlayList(owner.Id, "Two"));
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Equal(2, result.Value.TotalItemCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_NoPlayLists_ReturnsEmptyPage()
|
|
||||||
{
|
|
||||||
var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Empty(result.Value.Items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,16 +25,27 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
|
|||||||
var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp");
|
var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp");
|
||||||
await SeedAsync(owner, intent);
|
await SeedAsync(owner, intent);
|
||||||
|
|
||||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
Assert.False(result.IsError);
|
||||||
Assert.Equal(intent.Id, result.Value.Id);
|
Assert.Equal(intent.Id, result.Value.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAndLoadAsync_IntentIssuedForAnotherPurpose_ReturnsValidationError()
|
||||||
|
{
|
||||||
|
var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio);
|
||||||
|
await SeedAsync(owner, intent);
|
||||||
|
|
||||||
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound()
|
public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken);
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
@@ -46,7 +57,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
|
|||||||
var intent = TestEntities.UploadIntent(owner.Id);
|
var intent = TestEntities.UploadIntent(owner.Id);
|
||||||
await SeedAsync(owner, stranger, intent);
|
await SeedAsync(owner, stranger, intent);
|
||||||
|
|
||||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, TestContext.Current.CancellationToken);
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
@@ -57,7 +68,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
|
|||||||
var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed);
|
var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed);
|
||||||
await SeedAsync(owner, intent);
|
await SeedAsync(owner, intent);
|
||||||
|
|
||||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
@@ -68,7 +79,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
|
|||||||
var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1));
|
var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1));
|
||||||
await SeedAsync(owner, intent);
|
await SeedAsync(owner, intent);
|
||||||
|
|
||||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
@@ -83,7 +94,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
|
|||||||
var intent = TestEntities.UploadIntent(owner.Id);
|
var intent = TestEntities.UploadIntent(owner.Id);
|
||||||
await SeedAsync(owner, intent);
|
await SeedAsync(owner, intent);
|
||||||
|
|
||||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
@@ -101,7 +112,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
|
|||||||
var intent = TestEntities.UploadIntent(owner.Id);
|
var intent = TestEntities.UploadIntent(owner.Id);
|
||||||
await SeedAsync(owner, intent);
|
await SeedAsync(owner, intent);
|
||||||
|
|
||||||
var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ public sealed class CreateTrackCommandHandlerTests : HandlerTestBase
|
|||||||
(TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"),
|
(TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"),
|
||||||
TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3"));
|
TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_SameIntentForPictureAndAudio_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, picture.Id, [Genre.Pop]);
|
||||||
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent()
|
public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Musify.Application.Tests.TestSupport;
|
|
||||||
using Musify.Application.Users;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Users;
|
|
||||||
|
|
||||||
public sealed class CreateUserCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
|
||||||
private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateUserCommandHandler>());
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_NewId_CreatesUser()
|
|
||||||
{
|
|
||||||
var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe");
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Equal("Jane Doe", result.Value.Name);
|
|
||||||
Assert.Equal(1, result.Value.Id);
|
|
||||||
|
|
||||||
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
|
|
||||||
Assert.NotNull(stored);
|
|
||||||
Assert.Equal("JANE DOE", stored.NormalizedName);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_IdAlreadyExists_ReturnsConflict()
|
|
||||||
{
|
|
||||||
var existing = TestEntities.User(1);
|
|
||||||
await SeedAsync(existing);
|
|
||||||
|
|
||||||
var command = new CreateUserCommand(1, "Someone Else", null, null);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+33
@@ -216,4 +216,37 @@ public sealed class TicketValidationMiddlewareTests : IDisposable
|
|||||||
Assert.True(wasNextCalled());
|
Assert.True(wasNextCalled());
|
||||||
Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString());
|
Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("bytes=0-10,20-30")]
|
||||||
|
[InlineData("garbage")]
|
||||||
|
[InlineData("items=0-10")]
|
||||||
|
public async Task InvokeAsync_TicketWithMaxBytes_MultiOrInvalidRange_ReplacesItWithBoundedRange(string requestedRange)
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
||||||
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
|
||||||
|
context.Request.Headers.Range = requestedRange;
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.True(wasNextCalled());
|
||||||
|
Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_TicketWithMaxBytes_SuffixRange_ReturnsRangeNotSatisfiable()
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
||||||
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
|
||||||
|
context.Request.Headers.Range = "bytes=-500";
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.False(wasNextCalled());
|
||||||
|
Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+80
-152
@@ -279,24 +279,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/playlists": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/** Get Paginated PlayLists. */
|
|
||||||
get: operations["GetPlayLists"];
|
|
||||||
put?: never;
|
|
||||||
/** Create A New PlayList. */
|
|
||||||
post: operations["CreatePlayList"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/playlists/{id}": {
|
"/playlists/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -321,7 +303,7 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/** Get Paginated PlayLists For A User. */
|
/** Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner. */
|
||||||
get: operations["GetPlayListsByUserId"];
|
get: operations["GetPlayListsByUserId"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
@@ -366,6 +348,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/playlists": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Create A New PlayList. */
|
||||||
|
post: operations["CreatePlayList"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/playlists/{playlistId}": {
|
"/playlists/{playlistId}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -565,8 +564,7 @@ export interface paths {
|
|||||||
/** Get Paginated Users. */
|
/** Get Paginated Users. */
|
||||||
get: operations["GetUsers"];
|
get: operations["GetUsers"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Create A New User. */
|
post?: never;
|
||||||
post: operations["CreateUser"];
|
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
@@ -810,13 +808,6 @@ export interface components {
|
|||||||
/** @default false */
|
/** @default false */
|
||||||
isExplicit: boolean;
|
isExplicit: boolean;
|
||||||
};
|
};
|
||||||
CreateUserRequest: {
|
|
||||||
/** Format: int64 */
|
|
||||||
id: number | string;
|
|
||||||
name: string;
|
|
||||||
firstName: null | string;
|
|
||||||
secondName: null | string;
|
|
||||||
};
|
|
||||||
/** @enum {unknown} */
|
/** @enum {unknown} */
|
||||||
Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi";
|
Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi";
|
||||||
GenreResponse: {
|
GenreResponse: {
|
||||||
@@ -914,19 +905,6 @@ export interface components {
|
|||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
hasPreviousPage: boolean;
|
hasPreviousPage: boolean;
|
||||||
};
|
};
|
||||||
PaginatedResponseOfUserApplicationResponse: {
|
|
||||||
items: components["schemas"]["UserApplicationResponse"][];
|
|
||||||
/** Format: int32 */
|
|
||||||
pageNumber: number | string;
|
|
||||||
/** Format: int32 */
|
|
||||||
pageSize: number | string;
|
|
||||||
/** Format: int32 */
|
|
||||||
pageCount: number | string;
|
|
||||||
/** Format: int32 */
|
|
||||||
totalItemCount: number | string;
|
|
||||||
hasNextPage: boolean;
|
|
||||||
hasPreviousPage: boolean;
|
|
||||||
};
|
|
||||||
PlaybackPublicConfigResponse: {
|
PlaybackPublicConfigResponse: {
|
||||||
allowAnonymousListening: boolean;
|
allowAnonymousListening: boolean;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -1729,76 +1707,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
GetPlayLists: {
|
|
||||||
parameters: {
|
|
||||||
query?: {
|
|
||||||
pageNumber?: number | string;
|
|
||||||
pageSize?: number | string;
|
|
||||||
};
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["PaginatedResponseOfPlayListApplicationResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
CreatePlayList: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["CreatePlayListRequest"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Created */
|
|
||||||
201: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["PlayListApplicationResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Bad Request */
|
|
||||||
400: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Unauthorized */
|
|
||||||
401: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
/** @description Not Found */
|
|
||||||
404: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
GetPlayListById: {
|
GetPlayListById: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1834,7 +1742,6 @@ export interface operations {
|
|||||||
name?: string;
|
name?: string;
|
||||||
pageNumber?: number | string;
|
pageNumber?: number | string;
|
||||||
pageSize?: number | string;
|
pageSize?: number | string;
|
||||||
onlyPublic?: boolean;
|
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
@@ -1963,6 +1870,53 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
CreatePlayList: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreatePlayListRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlayListApplicationResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
UpdatePlayList: {
|
UpdatePlayList: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2493,46 +2447,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
CreateUser: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["CreateUserRequest"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Created */
|
|
||||||
201: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["UserApplicationResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Bad Request */
|
|
||||||
400: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Conflict */
|
|
||||||
409: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
GetUserById: {
|
GetUserById: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2611,6 +2525,20 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["ListeningStatsResponse"];
|
"application/json": components["schemas"]["ListeningStatsResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
GetLastTrackListenedByUserId: {
|
GetLastTrackListenedByUserId: {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
|||||||
api.GET('/playlists/users/{userId}', {
|
api.GET('/playlists/users/{userId}', {
|
||||||
params: {
|
params: {
|
||||||
path: { userId: params.id },
|
path: { userId: params.id },
|
||||||
query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE, onlyPublic: true }
|
query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user