wip - home sections

pull/13820/head
John Corser 3 weeks ago
parent d75216cf3a
commit 8abcaee846

@ -0,0 +1,320 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Models.HomeSectionDto;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Api.Controllers
{
/// <summary>
/// Home Section controller.
/// </summary>
[Route("Users/{userId}/HomeSections")]
[Authorize]
public class HomeSectionController : BaseJellyfinApiController
{
private readonly IHomeSectionManager _homeSectionManager;
private readonly IUserManager _userManager;
private readonly ILibraryManager _libraryManager;
private readonly IDtoService _dtoService;
/// <summary>
/// Initializes a new instance of the <see cref="HomeSectionController"/> class.
/// </summary>
/// <param name="homeSectionManager">Instance of the <see cref="IHomeSectionManager"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
public HomeSectionController(
IHomeSectionManager homeSectionManager,
IUserManager userManager,
ILibraryManager libraryManager,
IDtoService dtoService)
{
_homeSectionManager = homeSectionManager;
_userManager = userManager;
_libraryManager = libraryManager;
_dtoService = dtoService;
}
/// <summary>
/// Get all home sections.
/// </summary>
/// <param name="userId">User id.</param>
/// <response code="200">Home sections retrieved.</response>
/// <returns>An <see cref="IEnumerable{EnrichedHomeSectionDto}"/> containing the home sections.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<List<EnrichedHomeSectionDto>> GetHomeSections([FromRoute, Required] Guid userId)
{
var sections = _homeSectionManager.GetHomeSections(userId);
var result = new List<EnrichedHomeSectionDto>();
var user = _userManager.GetUserById(userId);
if (user == null)
{
return NotFound("User not found");
}
foreach (var section in sections)
{
var enrichedSection = new EnrichedHomeSectionDto
{
Id = null, // We'll need to retrieve the ID from the database
SectionOptions = section,
Items = GetItemsForSection(userId, section)
};
result.Add(enrichedSection);
}
return Ok(result);
}
/// <summary>
/// Get home section.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="sectionId">Section id.</param>
/// <response code="200">Home section retrieved.</response>
/// <response code="404">Home section not found.</response>
/// <returns>An <see cref="EnrichedHomeSectionDto"/> containing the home section.</returns>
[HttpGet("{sectionId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<EnrichedHomeSectionDto> GetHomeSection([FromRoute, Required] Guid userId, [FromRoute, Required] Guid sectionId)
{
var section = _homeSectionManager.GetHomeSection(userId, sectionId);
if (section == null)
{
return NotFound();
}
var user = _userManager.GetUserById(userId);
if (user == null)
{
return NotFound("User not found");
}
var result = new EnrichedHomeSectionDto
{
Id = sectionId,
SectionOptions = section,
Items = GetItemsForSection(userId, section)
};
return Ok(result);
}
/// <summary>
/// Create a new home section.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="dto">The home section dto.</param>
/// <response code="201">Home section created.</response>
/// <returns>An <see cref="HomeSectionDto"/> containing the new home section.</returns>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
public ActionResult<HomeSectionDto> CreateHomeSection([FromRoute, Required] Guid userId, [FromBody, Required] HomeSectionDto dto)
{
var sectionId = _homeSectionManager.CreateHomeSection(userId, dto.SectionOptions);
_homeSectionManager.SaveChanges();
dto.Id = sectionId;
return CreatedAtAction(nameof(GetHomeSection), new { userId, sectionId }, dto);
}
/// <summary>
/// Update a home section.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="sectionId">Section id.</param>
/// <param name="dto">The home section dto.</param>
/// <response code="204">Home section updated.</response>
/// <response code="404">Home section not found.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPut("{sectionId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateHomeSection(
[FromRoute, Required] Guid userId,
[FromRoute, Required] Guid sectionId,
[FromBody, Required] HomeSectionDto dto)
{
var success = _homeSectionManager.UpdateHomeSection(userId, sectionId, dto.SectionOptions);
if (!success)
{
return NotFound();
}
_homeSectionManager.SaveChanges();
return NoContent();
}
/// <summary>
/// Delete a home section.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="sectionId">Section id.</param>
/// <response code="204">Home section deleted.</response>
/// <response code="404">Home section not found.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpDelete("{sectionId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult DeleteHomeSection([FromRoute, Required] Guid userId, [FromRoute, Required] Guid sectionId)
{
var success = _homeSectionManager.DeleteHomeSection(userId, sectionId);
if (!success)
{
return NotFound();
}
_homeSectionManager.SaveChanges();
return NoContent();
}
private IEnumerable<BaseItemDto> GetItemsForSection(Guid userId, HomeSectionOptions options)
{
var user = _userManager.GetUserById(userId);
if (user == null)
{
return Array.Empty<BaseItemDto>();
}
switch (options.SectionType)
{
case HomeSectionType.None:
return Array.Empty<BaseItemDto>();
case HomeSectionType.SmallLibraryTiles:
return GetLibraryTilesHomeSectionItems(userId, true);
case HomeSectionType.LibraryButtons:
return GetLibraryTilesHomeSectionItems(userId, false);
// TODO: Implement GetActiveRecordingsHomeSectionItems
case HomeSectionType.ActiveRecordings:
return Array.Empty<BaseItemDto>();
// TODO: Implement GetResumeItemsHomeSectionItems
case HomeSectionType.Resume:
return Array.Empty<BaseItemDto>();
// TODO: Implement GetResumeAudioHomeSectionItems
case HomeSectionType.ResumeAudio:
return Array.Empty<BaseItemDto>();
case HomeSectionType.LatestMedia:
return GetLatestMediaHomeSectionItems(userId, options.MaxItems);
// TODO: Implement GetNextUpHomeSectionItems
case HomeSectionType.NextUp:
return Array.Empty<BaseItemDto>();
// TODO: Implement GetLiveTvHomeSectionItems
case HomeSectionType.LiveTv:
return Array.Empty<BaseItemDto>();
// TODO: Implement ResumeBookHomeSectionItems
case HomeSectionType.ResumeBook:
return Array.Empty<BaseItemDto>();
// Major TODO: Implement GetPinnedCollectionHomeSectionItems and add HomeSectionType.PinnedCollection
// See example at https://github.com/johnpc/jellyfin-plugin-home-sections/blob/main/Jellyfin.Plugin.HomeSections/Api/HomeSectionsController.cs
// Question: what should I do in the case of an unexpected HomeSectionType? Throw an exception?
default:
return Array.Empty<BaseItemDto>();
}
}
private IEnumerable<BaseItemDto> GetLatestMediaHomeSectionItems(Guid userId, int maxItems)
{
var user = _userManager.GetUserById(userId);
if (user == null)
{
return Array.Empty<BaseItemDto>();
}
var query = new InternalItemsQuery(user)
{
Recursive = true,
Limit = maxItems,
IsVirtualItem = false,
OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }
};
var items = _libraryManager.GetItemsResult(query);
return items.Items
.Where(i => i != null && (i is Movie || i is Series || i is Episode))
.Select(i =>
{
try
{
return _dtoService.GetBaseItemDto(i, new DtoOptions(), user);
}
catch (Exception ex)
{
// Log the error but don't crash
System.Diagnostics.Debug.WriteLine($"Error converting item {i.Id} to DTO: {ex.Message}");
return null;
}
})
.Where(dto => dto != null)
.Cast<BaseItemDto>();
}
private IEnumerable<BaseItemDto> GetLibraryTilesHomeSectionItems(Guid userId, bool smallTiles = false)
{
var user = _userManager.GetUserById(userId);
if (user == null)
{
return Array.Empty<BaseItemDto>();
}
// Get the user's view items (libraries)
var folders = _libraryManager.GetUserRootFolder()
.GetChildren(user, true)
.Where(i => i.IsFolder && !i.IsHidden)
.OrderBy(i => i.SortName)
.ToList();
// Convert to DTOs with appropriate options
var options = new DtoOptions
{
// For small tiles, we might want to limit the fields returned
// to make the response smaller
Fields = smallTiles
? new[] { ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId }
: new[]
{
ItemFields.PrimaryImageAspectRatio,
ItemFields.DisplayPreferencesId,
ItemFields.Overview,
ItemFields.ChildCount
}
};
return folders
.Select(i =>
{
try
{
return _dtoService.GetBaseItemDto(i, options, user);
}
catch (Exception)
{
return null;
}
})
.Where(dto => dto != null)
.Cast<BaseItemDto>();
}
}
}

@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
namespace Jellyfin.Api.Models.HomeSectionDto
{
/// <summary>
/// Home section dto with items.
/// </summary>
public class EnrichedHomeSectionDto
{
/// <summary>
/// Gets or sets the id.
/// </summary>
public Guid? Id { get; set; }
/// <summary>
/// Gets or sets the section options.
/// </summary>
public HomeSectionOptions SectionOptions { get; set; } = null!;
/// <summary>
/// Gets or sets the items.
/// </summary>
public IEnumerable<BaseItemDto> Items { get; set; } = Array.Empty<BaseItemDto>();
}
}

@ -0,0 +1,23 @@
using System;
using System.ComponentModel.DataAnnotations;
using MediaBrowser.Model.Configuration;
namespace Jellyfin.Api.Models.HomeSectionDto
{
/// <summary>
/// Home section DTO.
/// </summary>
public class HomeSectionDto
{
/// <summary>
/// Gets or sets the id.
/// </summary>
public Guid? Id { get; set; }
/// <summary>
/// Gets or sets the section options.
/// </summary>
[Required]
public HomeSectionOptions SectionOptions { get; set; } = new HomeSectionOptions();
}
}

@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller;
using MediaBrowser.Model.Configuration;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Users
{
/// <summary>
/// Manages the storage and retrieval of home sections through Entity Framework.
/// </summary>
public sealed class HomeSectionManager : IHomeSectionManager, IAsyncDisposable
{
private readonly JellyfinDbContext _dbContext;
/// <summary>
/// Initializes a new instance of the <see cref="HomeSectionManager"/> class.
/// </summary>
/// <param name="dbContextFactory">The database context factory.</param>
public HomeSectionManager(IDbContextFactory<JellyfinDbContext> dbContextFactory)
{
_dbContext = dbContextFactory.CreateDbContext();
// QUESTION FOR MAINTAINERS: How do I handle the db migration?
// I'm sure you don't want the table to be created lazily like this.
EnsureTableExists();
}
/// <summary>
/// Ensures that the UserHomeSections table exists in the database.
/// </summary>
private void EnsureTableExists()
{
try
{
// Check if table exists by attempting to query it
_dbContext.Database.ExecuteSqlRaw("SELECT 1 FROM UserHomeSections LIMIT 1");
}
catch
{
// Table doesn't exist, create it
_dbContext.Database.ExecuteSqlRaw(@"
CREATE TABLE IF NOT EXISTS UserHomeSections (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
UserId TEXT NOT NULL,
SectionId TEXT NOT NULL,
Name TEXT NOT NULL,
SectionType INTEGER NOT NULL,
Priority INTEGER NOT NULL,
MaxItems INTEGER NOT NULL,
SortOrder INTEGER NOT NULL,
SortBy INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS IX_UserHomeSections_UserId_SectionId ON UserHomeSections(UserId, SectionId);
");
// Add the migration record to __EFMigrationsHistory
_dbContext.Database.ExecuteSqlRaw(@"
CREATE TABLE IF NOT EXISTS __EFMigrationsHistory (
MigrationId TEXT NOT NULL CONSTRAINT PK___EFMigrationsHistory PRIMARY KEY,
ProductVersion TEXT NOT NULL
);
INSERT OR IGNORE INTO __EFMigrationsHistory (MigrationId, ProductVersion)
VALUES ('20250331000000_AddUserHomeSections', '3.1.0');
");
}
}
/// <inheritdoc />
public IList<HomeSectionOptions> GetHomeSections(Guid userId)
{
return _dbContext.UserHomeSections
.Where(section => section.UserId.Equals(userId))
.OrderBy(section => section.Priority)
.Select(section => new HomeSectionOptions
{
Name = section.Name,
SectionType = section.SectionType,
Priority = section.Priority,
MaxItems = section.MaxItems,
SortOrder = section.SortOrder,
SortBy = (Jellyfin.Database.Implementations.Enums.SortOrder)section.SortBy
})
.ToList();
}
/// <inheritdoc />
public HomeSectionOptions? GetHomeSection(Guid userId, Guid sectionId)
{
var section = _dbContext.UserHomeSections
.FirstOrDefault(section => section.UserId.Equals(userId) && section.SectionId.Equals(sectionId));
if (section == null)
{
return null;
}
return new HomeSectionOptions
{
Name = section.Name,
SectionType = section.SectionType,
Priority = section.Priority,
MaxItems = section.MaxItems,
SortOrder = section.SortOrder,
SortBy = (Jellyfin.Database.Implementations.Enums.SortOrder)section.SortBy
};
}
/// <inheritdoc />
public Guid CreateHomeSection(Guid userId, HomeSectionOptions options)
{
var sectionId = Guid.NewGuid();
var section = new UserHomeSection
{
UserId = userId,
SectionId = sectionId,
Name = options.Name,
SectionType = options.SectionType,
Priority = options.Priority,
MaxItems = options.MaxItems,
SortOrder = options.SortOrder,
SortBy = (int)options.SortBy
};
_dbContext.UserHomeSections.Add(section);
return sectionId;
}
/// <inheritdoc />
public bool UpdateHomeSection(Guid userId, Guid sectionId, HomeSectionOptions options)
{
var section = _dbContext.UserHomeSections
.FirstOrDefault(section => section.UserId.Equals(userId) && section.SectionId.Equals(sectionId));
if (section == null)
{
return false;
}
section.Name = options.Name;
section.SectionType = options.SectionType;
section.Priority = options.Priority;
section.MaxItems = options.MaxItems;
section.SortOrder = options.SortOrder;
section.SortBy = (int)options.SortBy;
return true;
}
/// <inheritdoc />
public bool DeleteHomeSection(Guid userId, Guid sectionId)
{
var section = _dbContext.UserHomeSections
.FirstOrDefault(section => section.UserId.Equals(userId) && section.SectionId.Equals(sectionId));
if (section == null)
{
return false;
}
_dbContext.UserHomeSections.Remove(section);
return true;
}
/// <inheritdoc />
public void SaveChanges()
{
_dbContext.SaveChanges();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _dbContext.DisposeAsync().ConfigureAwait(false);
}
}
}

@ -106,6 +106,9 @@ namespace Jellyfin.Server
})
.ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate);
// Register the HomeSectionManager
services.AddScoped<MediaBrowser.Controller.IHomeSectionManager, Jellyfin.Server.Implementations.Users.HomeSectionManager>();
services.AddHttpClient(NamedClient.MusicBrainz, c =>
{
c.DefaultRequestHeaders.UserAgent.Add(productHeader);

@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Model.Configuration;
namespace MediaBrowser.Controller
{
/// <summary>
/// Interface for managing home sections.
/// </summary>
public interface IHomeSectionManager
{
/// <summary>
/// Gets all home sections for a user.
/// </summary>
/// <param name="userId">The user id.</param>
/// <returns>A list of home section options.</returns>
IList<HomeSectionOptions> GetHomeSections(Guid userId);
/// <summary>
/// Gets a specific home section for a user.
/// </summary>
/// <param name="userId">The user id.</param>
/// <param name="sectionId">The section id.</param>
/// <returns>The home section options, or null if not found.</returns>
HomeSectionOptions? GetHomeSection(Guid userId, Guid sectionId);
/// <summary>
/// Creates a new home section for a user.
/// </summary>
/// <param name="userId">The user id.</param>
/// <param name="options">The home section options.</param>
/// <returns>The id of the newly created section.</returns>
Guid CreateHomeSection(Guid userId, HomeSectionOptions options);
/// <summary>
/// Updates a home section for a user.
/// </summary>
/// <param name="userId">The user id.</param>
/// <param name="sectionId">The section id.</param>
/// <param name="options">The updated home section options.</param>
/// <returns>True if the section was updated, false if it was not found.</returns>
bool UpdateHomeSection(Guid userId, Guid sectionId, HomeSectionOptions options);
/// <summary>
/// Deletes a home section for a user.
/// </summary>
/// <param name="userId">The user id.</param>
/// <param name="sectionId">The section id.</param>
/// <returns>True if the section was deleted, false if it was not found.</returns>
bool DeleteHomeSection(Guid userId, Guid sectionId);
/// <summary>
/// Saves changes to the database.
/// </summary>
void SaveChanges();
}
}

@ -0,0 +1,55 @@
#pragma warning disable CS1591
using System.ComponentModel;
using Jellyfin.Database.Implementations.Enums;
namespace MediaBrowser.Model.Configuration
{
/// <summary>
/// Options for a specific home section.
/// </summary>
public class HomeSectionOptions
{
/// <summary>
/// Initializes a new instance of the <see cref="HomeSectionOptions"/> class.
/// </summary>
public HomeSectionOptions()
{
Name = string.Empty;
}
/// <summary>
/// Gets or sets the name of the section.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Gets or sets the type of the section.
/// </summary>
public HomeSectionType SectionType { get; set; }
/// <summary>
/// Gets or sets the priority/order of this section (lower numbers appear first).
/// </summary>
[DefaultValue(0)]
public int Priority { get; set; } = 0;
/// <summary>
/// Gets or sets the maximum number of items to display in the section.
/// </summary>
[DefaultValue(10)]
public int MaxItems { get; set; } = 10;
/// <summary>
/// Gets or sets the sort order for items in this section.
/// </summary>
[DefaultValue(SortOrder.Ascending)]
public SortOrder SortOrder { get; set; } = SortOrder.Ascending;
/// <summary>
/// Gets or sets how items should be sorted in this section.
/// </summary>
[DefaultValue(SortOrder.Ascending)]
public SortOrder SortBy { get; set; } = SortOrder.Ascending;
}
}

@ -0,0 +1,88 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Jellyfin.Database.Implementations.Enums;
namespace Jellyfin.Database.Implementations.Entities
{
/// <summary>
/// An entity representing a user's home section.
/// </summary>
public class UserHomeSection
{
/// <summary>
/// Gets the Id.
/// </summary>
/// <remarks>
/// Identity. Required.
/// </remarks>
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; private set; }
/// <summary>
/// Gets or sets the user Id.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public Guid UserId { get; set; }
/// <summary>
/// Gets or sets the section Id.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public Guid SectionId { get; set; }
/// <summary>
/// Gets or sets the name of the section.
/// </summary>
/// <remarks>
/// Required. Max Length = 64.
/// </remarks>
[MaxLength(64)]
[StringLength(64)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the type of the section.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public HomeSectionType SectionType { get; set; }
/// <summary>
/// Gets or sets the priority/order of this section.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public int Priority { get; set; }
/// <summary>
/// Gets or sets the maximum number of items to display in the section.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public int MaxItems { get; set; }
/// <summary>
/// Gets or sets the sort order for items in this section.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public SortOrder SortOrder { get; set; }
/// <summary>
/// Gets or sets how items should be sorted in this section.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public int SortBy { get; set; }
}
}

@ -47,6 +47,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
/// </summary>
public DbSet<DisplayPreferences> DisplayPreferences => Set<DisplayPreferences>();
/// <summary>
/// Gets the <see cref="DbSet{TEntity}"/> containing the user home sections.
/// </summary>
public DbSet<UserHomeSection> UserHomeSections => Set<UserHomeSection>();
/// <summary>
/// Gets the <see cref="DbSet{TEntity}"/> containing the image infos.
/// </summary>

@ -0,0 +1,49 @@
using Jellyfin.Database.Implementations.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Jellyfin.Database.Implementations.ModelConfiguration
{
/// <summary>
/// Configuration for the UserHomeSection entity.
/// </summary>
public class UserHomeSectionConfiguration : IEntityTypeConfiguration<UserHomeSection>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<UserHomeSection> builder)
{
builder.ToTable("UserHomeSections");
builder.HasKey(e => e.Id);
builder.Property(e => e.UserId)
.IsRequired();
builder.Property(e => e.SectionId)
.IsRequired();
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(64);
builder.Property(e => e.SectionType)
.IsRequired();
builder.Property(e => e.Priority)
.IsRequired();
builder.Property(e => e.MaxItems)
.IsRequired();
builder.Property(e => e.SortOrder)
.IsRequired();
builder.Property(e => e.SortBy)
.IsRequired();
// Create a unique index on UserId + SectionId
builder.HasIndex(e => new { e.UserId, e.SectionId })
.IsUnique();
}
}
}

@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <summary>
/// Migration to add UserHomeSections table.
/// </summary>
public partial class AddUserHomeSections : Migration
{
/// <summary>
/// Builds the operations that will migrate the database 'up'.
/// </summary>
/// <param name="migrationBuilder">The migration builder.</param>
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserHomeSections",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(nullable: false),
SectionId = table.Column<string>(nullable: false),
Name = table.Column<string>(nullable: false),
SectionType = table.Column<int>(nullable: false),
Priority = table.Column<int>(nullable: false),
MaxItems = table.Column<int>(nullable: false),
SortOrder = table.Column<int>(nullable: false),
SortBy = table.Column<int>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserHomeSections", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_UserHomeSections_UserId_SectionId",
table: "UserHomeSections",
columns: new[] { "UserId", "SectionId" },
unique: true);
}
/// <summary>
/// Builds the operations that will migrate the database 'down'.
/// </summary>
/// <param name="migrationBuilder">The migration builder.</param>
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserHomeSections");
}
}
}

@ -0,0 +1,264 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Api.Controllers;
using Jellyfin.Api.Models.HomeSectionDto;
using Jellyfin.Api.Results;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;
namespace Jellyfin.Api.Tests.Controllers
{
public class HomeSectionControllerTests
{
private readonly Mock<IHomeSectionManager> _mockHomeSectionManager;
private readonly Mock<IUserManager> _mockUserManager;
private readonly Mock<ILibraryManager> _mockLibraryManager;
private readonly Mock<IDtoService> _mockDtoService;
private readonly HomeSectionController _controller;
private readonly Guid _userId = Guid.NewGuid();
public HomeSectionControllerTests()
{
_mockHomeSectionManager = new Mock<IHomeSectionManager>();
_mockUserManager = new Mock<IUserManager>();
_mockLibraryManager = new Mock<ILibraryManager>();
_mockDtoService = new Mock<IDtoService>();
_controller = new HomeSectionController(
_mockHomeSectionManager.Object,
_mockUserManager.Object,
_mockLibraryManager.Object,
_mockDtoService.Object);
// Setup user manager to return a non-null user for the test user ID
var mockUser = new Mock<User>();
_mockUserManager.Setup(m => m.GetUserById(_userId))
.Returns(mockUser.Object);
}
[Fact]
public void GetHomeSections_ReturnsOkResult_WithListOfSections()
{
// Arrange
var sections = new List<HomeSectionOptions>
{
new HomeSectionOptions
{
Name = "Test Section 1",
SectionType = HomeSectionType.LatestMedia,
Priority = 1,
MaxItems = 10,
SortOrder = SortOrder.Descending,
SortBy = SortOrder.Ascending
},
new HomeSectionOptions
{
Name = "Test Section 2",
SectionType = HomeSectionType.NextUp,
Priority = 2,
MaxItems = 5,
SortOrder = SortOrder.Ascending,
SortBy = SortOrder.Descending
}
};
_mockHomeSectionManager.Setup(m => m.GetHomeSections(_userId))
.Returns(sections);
// Act
var result = _controller.GetHomeSections(_userId);
// Assert
var okResult = Assert.IsAssignableFrom<OkObjectResult>(result.Result);
var returnValue = Assert.IsType<List<EnrichedHomeSectionDto>>(okResult.Value);
Assert.Equal(2, returnValue.Count);
Assert.Equal("Test Section 1", returnValue[0].SectionOptions.Name);
Assert.Equal("Test Section 2", returnValue[1].SectionOptions.Name);
}
[Fact]
public void GetHomeSection_WithValidId_ReturnsOkResult()
{
// Arrange
var sectionId = Guid.NewGuid();
var section = new HomeSectionOptions
{
Name = "Test Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 1,
MaxItems = 10,
SortOrder = SortOrder.Descending,
SortBy = SortOrder.Ascending
};
_mockHomeSectionManager.Setup(m => m.GetHomeSection(_userId, sectionId))
.Returns(section);
// Act
var result = _controller.GetHomeSection(_userId, sectionId);
// Assert
var okResult = Assert.IsAssignableFrom<OkObjectResult>(result.Result);
var returnValue = Assert.IsType<EnrichedHomeSectionDto>(okResult.Value);
Assert.Equal("Test Section", returnValue.SectionOptions.Name);
Assert.Equal(sectionId, returnValue.Id);
}
[Fact]
public void GetHomeSection_WithInvalidId_ReturnsNotFound()
{
// Arrange
var sectionId = Guid.NewGuid();
_mockHomeSectionManager.Setup(m => m.GetHomeSection(_userId, sectionId))
.Returns((HomeSectionOptions?)null);
// Act
var result = _controller.GetHomeSection(_userId, sectionId);
// Assert
Assert.IsType<NotFoundResult>(result.Result);
}
[Fact]
public void CreateHomeSection_ReturnsCreatedAtAction()
{
// Arrange
var sectionId = Guid.NewGuid();
var dto = new HomeSectionDto
{
SectionOptions = new HomeSectionOptions
{
Name = "New Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 3,
MaxItems = 15,
SortOrder = SortOrder.Ascending,
SortBy = SortOrder.Ascending
}
};
_mockHomeSectionManager.Setup(m => m.CreateHomeSection(_userId, dto.SectionOptions))
.Returns(sectionId);
// Act
var result = _controller.CreateHomeSection(_userId, dto);
// Assert
var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(result.Result);
var returnValue = Assert.IsType<HomeSectionDto>(createdAtActionResult.Value);
Assert.Equal("New Section", returnValue.SectionOptions.Name);
Assert.Equal(sectionId, returnValue.Id);
Assert.Equal("GetHomeSection", createdAtActionResult.ActionName);
// Check if RouteValues is not null before accessing its elements
Assert.NotNull(createdAtActionResult.RouteValues);
if (createdAtActionResult.RouteValues != null)
{
Assert.Equal(_userId, createdAtActionResult.RouteValues["userId"]);
Assert.Equal(sectionId, createdAtActionResult.RouteValues["sectionId"]);
}
_mockHomeSectionManager.Verify(m => m.SaveChanges(), Times.Once);
}
[Fact]
public void UpdateHomeSection_WithValidId_ReturnsNoContent()
{
// Arrange
var sectionId = Guid.NewGuid();
var dto = new HomeSectionDto
{
SectionOptions = new HomeSectionOptions
{
Name = "Updated Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 3,
MaxItems = 15,
SortOrder = SortOrder.Ascending,
SortBy = SortOrder.Ascending
}
};
_mockHomeSectionManager.Setup(m => m.UpdateHomeSection(_userId, sectionId, dto.SectionOptions))
.Returns(true);
// Act
var result = _controller.UpdateHomeSection(_userId, sectionId, dto);
// Assert
Assert.IsType<NoContentResult>(result);
_mockHomeSectionManager.Verify(m => m.SaveChanges(), Times.Once);
}
[Fact]
public void UpdateHomeSection_WithInvalidId_ReturnsNotFound()
{
// Arrange
var sectionId = Guid.NewGuid();
var dto = new HomeSectionDto
{
SectionOptions = new HomeSectionOptions
{
Name = "Updated Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 3,
MaxItems = 15,
SortOrder = SortOrder.Ascending,
SortBy = SortOrder.Ascending
}
};
_mockHomeSectionManager.Setup(m => m.UpdateHomeSection(_userId, sectionId, dto.SectionOptions))
.Returns(false);
// Act
var result = _controller.UpdateHomeSection(_userId, sectionId, dto);
// Assert
Assert.IsType<NotFoundResult>(result);
_mockHomeSectionManager.Verify(m => m.SaveChanges(), Times.Never);
}
[Fact]
public void DeleteHomeSection_WithValidId_ReturnsNoContent()
{
// Arrange
var sectionId = Guid.NewGuid();
_mockHomeSectionManager.Setup(m => m.DeleteHomeSection(_userId, sectionId))
.Returns(true);
// Act
var result = _controller.DeleteHomeSection(_userId, sectionId);
// Assert
Assert.IsType<NoContentResult>(result);
_mockHomeSectionManager.Verify(m => m.SaveChanges(), Times.Once);
}
[Fact]
public void DeleteHomeSection_WithInvalidId_ReturnsNotFound()
{
// Arrange
var sectionId = Guid.NewGuid();
_mockHomeSectionManager.Setup(m => m.DeleteHomeSection(_userId, sectionId))
.Returns(false);
// Act
var result = _controller.DeleteHomeSection(_userId, sectionId);
// Assert
Assert.IsType<NotFoundResult>(result);
_mockHomeSectionManager.Verify(m => m.SaveChanges(), Times.Never);
}
}
}

@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Jellyfin.Api.Models.HomeSectionDto;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Model.Configuration;
using Xunit;
namespace Jellyfin.Api.Tests.Integration
{
/// <summary>
/// Integration tests for the Home Section API.
/// These tests require a running Jellyfin server and should be run in a controlled environment.
/// </summary>
public sealed class HomeSectionApiTests : IDisposable
{
private readonly HttpClient _client;
private readonly Guid _userId = Guid.Parse("38a9e9be-b2a6-4790-85a3-62a01ca06dec"); // Test user ID
private readonly List<Guid> _createdSectionIds = new List<Guid>();
public HomeSectionApiTests()
{
// Setup HttpClient with base address pointing to your test server
_client = new HttpClient
{
BaseAddress = new Uri("http://localhost:8096/")
};
}
[Fact(Skip = "Integration test - requires running server")]
public async Task GetHomeSections_ReturnsSuccessStatusCode()
{
// Act
var response = await _client.GetAsync($"Users/{_userId}/HomeSections");
// Assert
response.EnsureSuccessStatusCode();
var sections = await response.Content.ReadFromJsonAsync<List<HomeSectionDto>>();
Assert.NotNull(sections);
}
[Fact(Skip = "Integration test - requires running server")]
public async Task CreateAndGetHomeSection_ReturnsCreatedSection()
{
// Arrange
var newSection = new HomeSectionDto
{
SectionOptions = new HomeSectionOptions
{
Name = "Integration Test Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 100,
MaxItems = 8,
SortOrder = SortOrder.Descending,
SortBy = SortOrder.Ascending
}
};
// Act - Create
var createResponse = await _client.PostAsJsonAsync($"Users/{_userId}/HomeSections", newSection);
// Assert - Create
createResponse.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var createdSection = await createResponse.Content.ReadFromJsonAsync<HomeSectionDto>();
Assert.NotNull(createdSection);
Assert.NotNull(createdSection.Id);
_createdSectionIds.Add(createdSection.Id.Value);
// Act - Get
var getResponse = await _client.GetAsync($"Users/{_userId}/HomeSections/{createdSection.Id}");
// Assert - Get
getResponse.EnsureSuccessStatusCode();
var retrievedSection = await getResponse.Content.ReadFromJsonAsync<HomeSectionDto>();
Assert.NotNull(retrievedSection);
Assert.Equal(createdSection.Id, retrievedSection.Id);
Assert.Equal("Integration Test Section", retrievedSection.SectionOptions.Name);
Assert.Equal(HomeSectionType.LatestMedia, retrievedSection.SectionOptions.SectionType);
Assert.Equal(100, retrievedSection.SectionOptions.Priority);
Assert.Equal(8, retrievedSection.SectionOptions.MaxItems);
Assert.Equal(SortOrder.Descending, retrievedSection.SectionOptions.SortOrder);
Assert.Equal(SortOrder.Ascending, retrievedSection.SectionOptions.SortBy);
}
[Fact(Skip = "Integration test - requires running server")]
public async Task UpdateHomeSection_ReturnsNoContent()
{
// Arrange - Create a section first
var newSection = new HomeSectionDto
{
SectionOptions = new HomeSectionOptions
{
Name = "Section To Update",
SectionType = HomeSectionType.NextUp,
Priority = 50,
MaxItems = 5,
SortOrder = SortOrder.Ascending,
SortBy = SortOrder.Ascending
}
};
var createResponse = await _client.PostAsJsonAsync($"Users/{_userId}/HomeSections", newSection);
createResponse.EnsureSuccessStatusCode();
var createdSection = await createResponse.Content.ReadFromJsonAsync<HomeSectionDto>();
Assert.NotNull(createdSection);
Assert.NotNull(createdSection.Id);
_createdSectionIds.Add(createdSection.Id.Value);
// Arrange - Update data
var updateSection = new HomeSectionDto
{
SectionOptions = new HomeSectionOptions
{
Name = "Updated Section Name",
SectionType = HomeSectionType.LatestMedia,
Priority = 25,
MaxItems = 12,
SortOrder = SortOrder.Descending,
SortBy = SortOrder.Descending
}
};
// Act
var updateResponse = await _client.PutAsJsonAsync($"Users/{_userId}/HomeSections/{createdSection.Id}", updateSection);
// Assert
Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode);
// Verify the update
var getResponse = await _client.GetAsync($"Users/{_userId}/HomeSections/{createdSection.Id}");
getResponse.EnsureSuccessStatusCode();
var retrievedSection = await getResponse.Content.ReadFromJsonAsync<HomeSectionDto>();
Assert.NotNull(retrievedSection);
Assert.Equal("Updated Section Name", retrievedSection.SectionOptions.Name);
Assert.Equal(HomeSectionType.LatestMedia, retrievedSection.SectionOptions.SectionType);
Assert.Equal(25, retrievedSection.SectionOptions.Priority);
Assert.Equal(12, retrievedSection.SectionOptions.MaxItems);
Assert.Equal(SortOrder.Descending, retrievedSection.SectionOptions.SortOrder);
Assert.Equal(SortOrder.Descending, retrievedSection.SectionOptions.SortBy);
}
[Fact(Skip = "Integration test - requires running server")]
public async Task DeleteHomeSection_ReturnsNoContent()
{
// Arrange - Create a section first
var newSection = new HomeSectionDto
{
SectionOptions = new HomeSectionOptions
{
Name = "Section To Delete",
SectionType = HomeSectionType.LatestMedia,
Priority = 75,
MaxItems = 3,
SortOrder = SortOrder.Ascending,
SortBy = SortOrder.Descending
}
};
var createResponse = await _client.PostAsJsonAsync($"Users/{_userId}/HomeSections", newSection);
createResponse.EnsureSuccessStatusCode();
var createdSection = await createResponse.Content.ReadFromJsonAsync<HomeSectionDto>();
Assert.NotNull(createdSection);
Assert.NotNull(createdSection.Id);
// Act
var deleteResponse = await _client.DeleteAsync($"Users/{_userId}/HomeSections/{createdSection.Id}");
// Assert
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
// Verify it's gone
var getResponse = await _client.GetAsync($"Users/{_userId}/HomeSections/{createdSection.Id}");
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
}
public void Dispose()
{
// Clean up any sections created during tests
foreach (var sectionId in _createdSectionIds)
{
try
{
_client.DeleteAsync($"Users/{_userId}/HomeSections/{sectionId}").Wait();
}
catch
{
// Ignore cleanup errors
}
}
_client.Dispose();
GC.SuppressFinalize(this);
}
}
}

@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Jellyfin.Api.Models.HomeSectionDto;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Model.Configuration;
using Xunit;
namespace Jellyfin.Api.Tests.Models.HomeSectionDto
{
public class HomeSectionDtoTests
{
[Fact]
public void HomeSectionDto_DefaultConstructor_InitializesProperties()
{
// Act
var dto = new Jellyfin.Api.Models.HomeSectionDto.HomeSectionDto();
// Assert
Assert.Null(dto.Id);
Assert.NotNull(dto.SectionOptions);
}
[Fact]
public void HomeSectionDto_WithValues_StoresCorrectly()
{
// Arrange
var id = Guid.NewGuid();
var options = new HomeSectionOptions
{
Name = "Test Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 1,
MaxItems = 10,
SortOrder = SortOrder.Descending,
SortBy = SortOrder.Ascending
};
// Act
var dto = new Jellyfin.Api.Models.HomeSectionDto.HomeSectionDto
{
Id = id,
SectionOptions = options
};
// Assert
Assert.Equal(id, dto.Id);
Assert.Same(options, dto.SectionOptions);
Assert.Equal("Test Section", dto.SectionOptions.Name);
Assert.Equal(HomeSectionType.LatestMedia, dto.SectionOptions.SectionType);
Assert.Equal(1, dto.SectionOptions.Priority);
Assert.Equal(10, dto.SectionOptions.MaxItems);
Assert.Equal(SortOrder.Descending, dto.SectionOptions.SortOrder);
Assert.Equal(SortOrder.Ascending, dto.SectionOptions.SortBy);
}
[Fact]
public void HomeSectionDto_SectionOptionsRequired_ValidationFails()
{
// Arrange
var dto = new Jellyfin.Api.Models.HomeSectionDto.HomeSectionDto
{
Id = Guid.NewGuid(),
SectionOptions = new HomeSectionOptions() // Use empty options instead of null
};
// Set SectionOptions to null for validation test
// This is a workaround for non-nullable reference types
var validationContext = new ValidationContext(dto);
var validationResults = new List<ValidationResult>();
// Use reflection to set the SectionOptions to null for validation testing
var propertyInfo = dto.GetType().GetProperty("SectionOptions");
propertyInfo?.SetValue(dto, null);
// Act
var isValid = Validator.TryValidateObject(dto, validationContext, validationResults, true);
// Assert
Assert.False(isValid);
Assert.Single(validationResults);
Assert.Contains("SectionOptions", validationResults[0].MemberNames);
}
[Fact]
public void HomeSectionOptions_DefaultConstructor_InitializesProperties()
{
// Act
var options = new HomeSectionOptions();
// Assert
Assert.Equal(string.Empty, options.Name);
Assert.Equal(HomeSectionType.None, options.SectionType);
Assert.Equal(0, options.Priority);
Assert.Equal(10, options.MaxItems);
Assert.Equal(SortOrder.Ascending, options.SortOrder);
Assert.Equal(SortOrder.Ascending, options.SortBy);
}
[Fact]
public void HomeSectionOptions_WithValues_StoresCorrectly()
{
// Act
var options = new HomeSectionOptions
{
Name = "Custom Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 5,
MaxItems = 20,
SortOrder = SortOrder.Descending,
SortBy = SortOrder.Descending
};
// Assert
Assert.Equal("Custom Section", options.Name);
Assert.Equal(HomeSectionType.LatestMedia, options.SectionType);
Assert.Equal(5, options.Priority);
Assert.Equal(20, options.MaxItems);
Assert.Equal(SortOrder.Descending, options.SortOrder);
Assert.Equal(SortOrder.Descending, options.SortBy);
}
}
}

@ -0,0 +1,320 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Server.Implementations.Users;
using MediaBrowser.Model.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Users
{
public sealed class HomeSectionManagerTests : IAsyncDisposable
{
private readonly Mock<IDbContextFactory<JellyfinDbContext>> _mockDbContextFactory;
private readonly Mock<JellyfinDbContext> _mockDbContext;
private readonly HomeSectionManager _manager;
private readonly Guid _userId = Guid.NewGuid();
private readonly List<UserHomeSection> _homeSections;
private readonly Mock<DbSet<UserHomeSection>> _mockDbSet;
public HomeSectionManagerTests()
{
_homeSections = new List<UserHomeSection>();
// Setup mock DbSet for UserHomeSections
_mockDbSet = CreateMockDbSet(_homeSections);
// Setup mock DbContext
var mockLogger = new Mock<ILogger<JellyfinDbContext>>();
var mockProvider = new Mock<IJellyfinDatabaseProvider>();
_mockDbContext = new Mock<JellyfinDbContext>(
new DbContextOptions<JellyfinDbContext>(),
mockLogger.Object,
mockProvider.Object);
// Setup the property to return our mock DbSet
_mockDbContext.Setup(c => c.Set<UserHomeSection>()).Returns(_mockDbSet.Object);
// Setup mock DbContextFactory
_mockDbContextFactory = new Mock<IDbContextFactory<JellyfinDbContext>>();
_mockDbContextFactory.Setup(f => f.CreateDbContext()).Returns(_mockDbContext.Object);
_manager = new HomeSectionManager(_mockDbContextFactory.Object);
}
[Fact]
public void GetHomeSections_ReturnsAllSectionsForUser()
{
// Arrange
var sectionId1 = Guid.NewGuid();
var sectionId2 = Guid.NewGuid();
_homeSections.AddRange(new[]
{
new UserHomeSection
{
UserId = _userId,
SectionId = sectionId1,
Name = "Test Section 1",
SectionType = HomeSectionType.LatestMedia,
Priority = 1,
MaxItems = 10,
SortOrder = SortOrder.Descending,
SortBy = (int)SortOrder.Ascending
},
new UserHomeSection
{
UserId = _userId,
SectionId = sectionId2,
Name = "Test Section 2",
SectionType = HomeSectionType.NextUp,
Priority = 2,
MaxItems = 5,
SortOrder = SortOrder.Ascending,
SortBy = (int)SortOrder.Descending
},
new UserHomeSection
{
UserId = Guid.NewGuid(), // Different user
SectionId = Guid.NewGuid(),
Name = "Other User Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 1,
MaxItems = 15,
SortOrder = SortOrder.Ascending,
SortBy = (int)SortOrder.Ascending
}
});
// Act
var result = _manager.GetHomeSections(_userId);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("Test Section 1", result[0].Name);
Assert.Equal("Test Section 2", result[1].Name);
}
[Fact]
public void GetHomeSection_WithValidId_ReturnsSection()
{
// Arrange
var sectionId = Guid.NewGuid();
_homeSections.Add(new UserHomeSection
{
UserId = _userId,
SectionId = sectionId,
Name = "Test Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 1,
MaxItems = 10,
SortOrder = SortOrder.Descending,
SortBy = (int)SortOrder.Ascending
});
// Act
var result = _manager.GetHomeSection(_userId, sectionId);
// Assert
Assert.NotNull(result);
Assert.Equal("Test Section", result.Name);
Assert.Equal(HomeSectionType.LatestMedia, result.SectionType);
Assert.Equal(1, result.Priority);
Assert.Equal(10, result.MaxItems);
Assert.Equal(SortOrder.Descending, result.SortOrder);
Assert.Equal(SortOrder.Ascending, result.SortBy);
}
[Fact]
public void GetHomeSection_WithInvalidId_ReturnsNull()
{
// Arrange
var sectionId = Guid.NewGuid();
// Act
var result = _manager.GetHomeSection(_userId, sectionId);
// Assert
Assert.Null(result);
}
[Fact]
public void CreateHomeSection_AddsNewSectionToDatabase()
{
// Arrange
var options = new HomeSectionOptions
{
Name = "New Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 3,
MaxItems = 15,
SortOrder = SortOrder.Ascending,
SortBy = SortOrder.Ascending
};
// Act
var sectionId = _manager.CreateHomeSection(_userId, options);
// Assert
_mockDbSet.Verify(
m => m.Add(
It.Is<UserHomeSection>(s =>
s.UserId.Equals(_userId) &&
s.SectionId.Equals(sectionId) &&
s.Name == "New Section" &&
s.SectionType == HomeSectionType.LatestMedia &&
s.Priority == 3 &&
s.MaxItems == 15 &&
s.SortOrder == SortOrder.Ascending &&
s.SortBy == (int)SortOrder.Ascending)),
Times.Once);
}
[Fact]
public void UpdateHomeSection_WithValidId_UpdatesSection()
{
// Arrange
var sectionId = Guid.NewGuid();
_homeSections.Add(new UserHomeSection
{
UserId = _userId,
SectionId = sectionId,
Name = "Original Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 1,
MaxItems = 10,
SortOrder = SortOrder.Descending,
SortBy = (int)SortOrder.Ascending
});
var options = new HomeSectionOptions
{
Name = "Updated Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 3,
MaxItems = 15,
SortOrder = SortOrder.Ascending,
SortBy = SortOrder.Descending
};
// Act
var result = _manager.UpdateHomeSection(_userId, sectionId, options);
// Assert
Assert.True(result);
var section = _homeSections.First(s => s.SectionId.Equals(sectionId));
Assert.Equal("Updated Section", section.Name);
Assert.Equal(HomeSectionType.LatestMedia, section.SectionType);
Assert.Equal(3, section.Priority);
Assert.Equal(15, section.MaxItems);
Assert.Equal(SortOrder.Ascending, section.SortOrder);
Assert.Equal((int)SortOrder.Descending, section.SortBy);
}
[Fact]
public void UpdateHomeSection_WithInvalidId_ReturnsFalse()
{
// Arrange
var sectionId = Guid.NewGuid();
var options = new HomeSectionOptions
{
Name = "Updated Section",
SectionType = HomeSectionType.LatestMedia,
Priority = 3,
MaxItems = 15,
SortOrder = SortOrder.Ascending,
SortBy = SortOrder.Descending
};
// Act
var result = _manager.UpdateHomeSection(_userId, sectionId, options);
// Assert
Assert.False(result);
}
[Fact]
public void DeleteHomeSection_WithValidId_RemovesSection()
{
// Arrange
var sectionId = Guid.NewGuid();
var section = new UserHomeSection
{
UserId = _userId,
SectionId = sectionId,
Name = "Section to Delete",
SectionType = HomeSectionType.LatestMedia,
Priority = 1,
MaxItems = 10,
SortOrder = SortOrder.Descending,
SortBy = (int)SortOrder.Ascending
};
_homeSections.Add(section);
// Act
var result = _manager.DeleteHomeSection(_userId, sectionId);
// Assert
Assert.True(result);
_mockDbSet.Verify(m => m.Remove(section), Times.Once);
}
[Fact]
public void DeleteHomeSection_WithInvalidId_ReturnsFalse()
{
// Arrange
var sectionId = Guid.NewGuid();
// Act
var result = _manager.DeleteHomeSection(_userId, sectionId);
// Assert
Assert.False(result);
_mockDbSet.Verify(m => m.Remove(It.IsAny<UserHomeSection>()), Times.Never);
}
[Fact]
public void SaveChanges_CallsSaveChangesOnDbContext()
{
// Act
_manager.SaveChanges();
// Assert
_mockDbContext.Verify(c => c.SaveChanges(), Times.Once);
}
private static Mock<DbSet<T>> CreateMockDbSet<T>(List<T> data)
where T : class
{
var queryable = data.AsQueryable();
var mockDbSet = new Mock<DbSet<T>>();
mockDbSet.As<IQueryable<T>>().Setup(m => m.Provider).Returns(queryable.Provider);
mockDbSet.As<IQueryable<T>>().Setup(m => m.Expression).Returns(queryable.Expression);
mockDbSet.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(queryable.ElementType);
mockDbSet.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(() => queryable.GetEnumerator());
mockDbSet.Setup(m => m.Add(It.IsAny<T>())).Callback<T>(data.Add);
mockDbSet.Setup(m => m.Remove(It.IsAny<T>())).Callback<T>(item => data.Remove(item));
return mockDbSet;
}
public async ValueTask DisposeAsync()
{
await _manager.DisposeAsync().ConfigureAwait(false);
GC.SuppressFinalize(this);
}
}
}
Loading…
Cancel
Save