diff --git a/AGENTS.md b/AGENTS.md index c23bde6..57d9584 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,11 +24,12 @@ dotnet build # compiles SCSS automatically - Aggressive gradient mixins in `Styles/_gradients.scss` - Works in Docker — MSBuild task runs during `dotnet build`/`dotnet publish`, no hosted service -## Projects (4 in solution) +## Projects (5 in solution) | Project | Role | Entrypoint | |---|---|---| | **Butter** | Shared class library (DTOs, enums, MIME types) | — | +| **Lactose.Analyzers** | Roslyn analyzer — enforces `DateTime.UtcNow` (not `DateTime.Now`) as error MS001 | — | | **Lactose** | ASP.NET Core Web API — controllers, EF Core, repos, background jobs | `Lactose/Program.cs` | | **MilkStream** | Blazor WASM host — serves WASM files + dynamic `/appsettings.json` | `MilkStream/Program.cs` | | **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services, shared UI components (ModalFrame, EmptyState) | `MilkStream.Client/Program.cs` | @@ -53,6 +54,8 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta - **EF Core Tools v8.0.15 vs EF Core Design v10.0.9:** Version mismatch may cause `dotnet ef` CLI quirks. - **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (default) for login, and one named `"MilkstreamClient"` which has `JwtTokenRefresher` as a `DelegatingHandler`. The handler transparently refreshes expired tokens pre-flight and retries once on 401 — authenticated services must **never** handle token refreshes manually. Do not add `SendWithRefreshAsync` or similar wrappers; rely solely on the named client. - **HTTPS redirection is commented out** in Lactose — API does not enforce HTTPS. +- **`SharedWith.Any()` EF Core translation:** Cannot be translated to SQL through many-to-many join tables (`AlbumAsset`, `AssetUser`). When visibility filtering includes `SharedWith.Any(u => u.Id == userId)`, you must materialize records first with `.Include().ThenInclude()` chains, then filter client-side in C#. The two-pass pagination approach preserves page-limited loading — see `PersonRepository.SearchQuery` and `AlbumRepository.SearchQuery` for the pattern. +- **DateTime analyzer:** `Lactose.Analyzers` treats `DateTime.Now` as error MS001. Always use `DateTime.UtcNow`. All timestamp columns use `timestamp with time zone`. ## XML docs enforced @@ -65,6 +68,7 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta - **Repository `Save()` must be called explicitly** after insert/update/delete - **Enum prefix `E`** (e.g. `EAccessLevel`, `EAssetType`, `EJobStatus`) - **JWT access token**: 10 min, refresh token: 60 min (constants in `LactoseAuthService.cs`) +- **Pagination is zero-based** everywhere — `Page = 0` is the first page. All repositories use `Skip(page * pageSize)`. All controllers validate `Page < 0` (reject negative) and `PageSize < 1 || PageSize > 250` (clamp 1–250). - **Conventional Commits** enforced via `cliff.toml`; changelog generated with `git-cliff` - **Granular commits** — commit each logical change separately (e.g., DTO change, controller logic, UI component) with a descriptive commit message - **Reuse API endpoints** — prefer adding optional query parameters to existing endpoints over creating new routes. New endpoints are a last resort when the existing ones fundamentally cannot serve the need. @@ -90,8 +94,25 @@ dotnet ef migrations add --project Lactose dotnet ef database update --project Lactose ``` +**Database indexes for performance** should be defined using the **fluent API** in `LactoseDbContext.OnModelCreating` via `HasIndex().HasFilter()`. Do not use the `[Index]` data annotation — the `Filter` parameter is not available in the current package configuration (EF Core Tools v8.0.15 vs Design v10.0.9 mismatch). Example: + +```csharp +modelBuilder.Entity().HasIndex(e => new { e.IsPubliclyShared, e.OwnerId }) + .HasDatabaseName("IX_Assets_VisibleForSearch") + .HasFilter("\"DeletedAt\" IS NULL"); +``` + +- **Trigram GIN indexes** on `People.Name` and `Albums.Title` use `HasMethod("gin")` with `HasOperators("gin_trgm_ops")` for case-insensitive `ILike` search. +- **pgvector and pg_trgm** extensions are enabled in `OnModelCreating` via `HasPostgresExtension`. + ## Tests -No test project found in solution. +No unit/integration test project exists. API endpoint testing is done via **REST Client `.http` files**: + +- **`Lactose/WepApiTest.http`** — 67 numbered tests covering all API endpoints across 10 sections (Auth → User CRUD → Person → Album → Tag → Asset → Stats → Settings → Cleanup). Tests verify authorization at every level (anonymous, user, curator, admin). Self-contained — all test data is created at runtime and cleaned up. +- **`Lactose/http-client.env.json`** — provides pre-filled variable values (`userName`, `curatorName`, etc.) for the REST Client extension. +- Open in VS Code/Rider with the REST Client extension and run individual tests or the full suite. +- Server must be running locally (default `http://localhost:5162`). +- Paginated list tests (people, albums, users) use `?page=0&pageSize=5` — change these carefully. diff --git a/Butter/Dtos/PagedParametersDTO.cs b/Butter/Dtos/PagedParametersDTO.cs index 8f962b2..992168a 100644 --- a/Butter/Dtos/PagedParametersDTO.cs +++ b/Butter/Dtos/PagedParametersDTO.cs @@ -4,6 +4,12 @@ namespace Butter.Dtos; /// Represents pagination parameters for a query. /// public class PagedParametersDto { + + /// + /// Contains the global constant for max pages. + /// + public const int MaxPageSize = 250; + /// /// Gets or sets the page number (zero-based). /// diff --git a/Lactose/Context/LactoseDbContext.cs b/Lactose/Context/LactoseDbContext.cs index 3132d39..47f81b4 100644 --- a/Lactose/Context/LactoseDbContext.cs +++ b/Lactose/Context/LactoseDbContext.cs @@ -60,6 +60,7 @@ public class LactoseDbContext : DbContext { /// protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasPostgresExtension("vector"); + modelBuilder.HasPostgresExtension("pg_trgm"); //Album Relationships modelBuilder.Entity().HasOne(e => e.UserOwner).WithMany(e => e.OwnedAlbums); modelBuilder.Entity().HasOne(e => e.PersonOwner).WithMany(e => e.Albums); @@ -72,6 +73,19 @@ public class LactoseDbContext : DbContext { modelBuilder.Entity().HasMany(e => e.Tags).WithMany(e => e.Assets); modelBuilder.Entity().HasMany(e => e.Faces).WithOne(e => e.Asset); modelBuilder.Entity().HasOne(e => e.Folder).WithMany(e => e.Assets); + modelBuilder.Entity().HasIndex(e => new { e.IsPubliclyShared, e.OwnerId }) + .HasDatabaseName("IX_Assets_VisibleForSearch") + .HasFilter("\"DeletedAt\" IS NULL"); + // People + modelBuilder.Entity().HasIndex(p => p.Name) + .HasDatabaseName("IX_People_Name_Trgm") + .HasMethod("gin") + .HasOperators("gin_trgm_ops"); + // Albums + modelBuilder.Entity().HasIndex(a => a.Title) + .HasDatabaseName("IX_Albums_Title_Trgm") + .HasMethod("gin") + .HasOperators("gin_trgm_ops"); //Face Relationships modelBuilder.Entity().HasOne(e => e.Asset).WithMany(e => e.Faces); modelBuilder.Entity().HasOne(e => e.Person).WithMany(e => e.Faces); diff --git a/Lactose/Controllers/AlbumController.cs b/Lactose/Controllers/AlbumController.cs index 32a6375..7f797a3 100644 --- a/Lactose/Controllers/AlbumController.cs +++ b/Lactose/Controllers/AlbumController.cs @@ -78,32 +78,21 @@ public class AlbumController( public ActionResult> Search([FromQuery] AlbumSearchParametersDto pagingOptions) { var uid = authService.GetUserData(User)?.Id; var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User; - - if(pagingOptions.Page < 0 || pagingOptions.PageSize < 0) return BadRequest(); - + + if(pagingOptions.Page < 0 || pagingOptions.PageSize < 1 || pagingOptions.PageSize > PagedParametersDto.MaxPageSize) return BadRequest(); + var albums = albumRepository.SearchQuery( pagingOptions.Search ?? string.Empty, pagingOptions.Page, pagingOptions.PageSize, pagingOptions.SortBy, pagingOptions.SortAsc, - pagingOptions.Unassigned + pagingOptions.Unassigned, + uid ?? default, + accessLevel ).ToList(); - switch (accessLevel) { - default: - case EAccessLevel.User: - List filteredAlbums; - // Only publicly shared albums or owned by user - filteredAlbums = albums.Where(album => album.Assets != null && (album.UserOwnerId == uid || album.Assets.Any(asset => asset.IsPubliclyShared && asset.DeletedAt == null))).ToList(); - // Add shared albums - filteredAlbums.AddRange(albums.Where(album => album.Assets != null && album.Assets.Any(x => x.DeletedAt == null && x.SharedWith!.Any(user => user.Id == uid)))); - return Ok(filteredAlbums.Select(x => x.ToAlbumPreviewDto()).ToList()); - case EAccessLevel.Curator: - case EAccessLevel.Admin: - // All albums - return Ok(albums.Select(x => x.ToAlbumPreviewDto()).ToList()); - } + return Ok(albums); } /// diff --git a/Lactose/Controllers/AssetController.cs b/Lactose/Controllers/AssetController.cs index b72f6ec..b940039 100644 --- a/Lactose/Controllers/AssetController.cs +++ b/Lactose/Controllers/AssetController.cs @@ -124,7 +124,7 @@ public class AssetController( return BadRequest(); } - if (searchOptionsDto.Page < 1 || searchOptionsDto.PageSize < 1) { + if (searchOptionsDto.Page < 0 || searchOptionsDto.PageSize < 1 || searchOptionsDto.PageSize > PagedParametersDto.MaxPageSize) { logger.LogWarning("Invalid pagination parameters provided"); return BadRequest(); } diff --git a/Lactose/Controllers/JobsController.cs b/Lactose/Controllers/JobsController.cs index 2dc51bd..b5125ef 100644 --- a/Lactose/Controllers/JobsController.cs +++ b/Lactose/Controllers/JobsController.cs @@ -1,3 +1,4 @@ +using Butter.Dtos; using Butter.Dtos.Jobs; using Butter.Types; using Lactose.Jobs; @@ -34,7 +35,8 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La /// [HttpGet("past")] [Authorize(Roles = "Admin")] - public ActionResult GetPast([FromQuery] int page = 1, [FromQuery] int pageSize = 5) { + public ActionResult GetPast([FromQuery] int page = 0, [FromQuery] int pageSize = 5) { + if (page < 0 || pageSize < 1 || pageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); } var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User; if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); } var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize); diff --git a/Lactose/Controllers/PersonController.cs b/Lactose/Controllers/PersonController.cs index 778d982..3a5e656 100644 --- a/Lactose/Controllers/PersonController.cs +++ b/Lactose/Controllers/PersonController.cs @@ -102,16 +102,17 @@ public class PersonController( var uid = authService.GetUserData(User)?.Id; var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User; + if (pagedSearch.Page < 0 || pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); } + var people = personRepository.SearchQuery( pagedSearch.Search ?? string.Empty, pagedSearch.Page, pagedSearch.PageSize, pagedSearch.SortBy, pagedSearch.SortAsc, - uid!.Value, + uid ?? default, accessLevel - ).Select(p => p.ToPersonPreviewDto()) - .ToList(); + ).ToList(); return Ok(people); } diff --git a/Lactose/Controllers/TagController.cs b/Lactose/Controllers/TagController.cs index ddc5623..246656e 100644 --- a/Lactose/Controllers/TagController.cs +++ b/Lactose/Controllers/TagController.cs @@ -67,10 +67,10 @@ public class TagController( /// Pagination and search parameters. /// A list of tags matching the search criteria. [HttpGet] - public ActionResult> GetAll(PagedSearchParametersDto pagedSearch) { + public ActionResult> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) { pagedSearch.Search ??= string.Empty; if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); } - if(pagedSearch.PageSize < 0) { return BadRequest("PageSize must be greater than 0"); } + if(pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest($"PageSize must be between 1 and {PagedParametersDto.MaxPageSize}"); } List tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize); List tagsDto = []; diff --git a/Lactose/Controllers/UserController.cs b/Lactose/Controllers/UserController.cs index c2628b5..c620d70 100644 --- a/Lactose/Controllers/UserController.cs +++ b/Lactose/Controllers/UserController.cs @@ -97,18 +97,10 @@ namespace Lactose.Controllers { /// Gets all users. /// /// A list of all users. + [Authorize(Roles = "Admin")] [HttpGet] public ActionResult> GetAll() { logger.LogDebug("Sending Users list"); - LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User); - - if (authenticatedUser == null) { - logger.LogWarning(@"Anonymous User hasn't enough permissions to create a User. it shouldn't be here"); - return Unauthorized("Unauthorized"); - } - - //TODO: Lower level of access should fill less information of users - //if (false) return Unauthorized(); return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto()).ToList()); } diff --git a/Lactose/Mapper/AlbumMapper.cs b/Lactose/Mapper/AlbumMapper.cs index 78e8368..5144ed3 100644 --- a/Lactose/Mapper/AlbumMapper.cs +++ b/Lactose/Mapper/AlbumMapper.cs @@ -1,4 +1,5 @@ using Butter.Dtos.Album; +using Butter.Types; using Lactose.Models; namespace Lactose.Mapper; @@ -7,6 +8,44 @@ namespace Lactose.Mapper; /// provides mapping methods for converting Album objects to DTOs. /// public static class AlbumMapper { + /// + /// Maps an Album object to an AlbumPreviewDto object. + /// + /// The album to map. + /// An AlbumPreviewDto object. + public static AlbumPreviewDto ToAlbumPreviewDto(this Album album) => new AlbumPreviewDto { + Id = album.Id, + Name = album.Title, + Owner = album.UserOwnerId, + Person = album.PersonOwnerId, + PersonName = album.PersonOwner?.Name, + CoverAssetId = album.CoverAssetId, + AssetCount = album.Assets?.Count ?? 0 + }; + + /// + /// Maps an Album object to an AlbumPreviewDto with visibility-aware asset counting. + /// + /// The album to map. + /// The requesting user's ID (used for visibility filtering). + /// The requesting user's access level. + /// An AlbumPreviewDto object. + public static AlbumPreviewDto ToAlbumPreviewDto(this Album album, Guid userId, EAccessLevel accessLevel) => new AlbumPreviewDto { + Id = album.Id, + Name = album.Title, + Owner = album.UserOwnerId, + Person = album.PersonOwnerId, + PersonName = album.PersonOwner?.Name, + CoverAssetId = album.CoverAssetId, + AssetCount = accessLevel == EAccessLevel.User + ? album.Assets?.Count(asset => + asset.DeletedAt == null && ( + asset.IsPubliclyShared || + asset.OwnerId == userId || + (asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId)))) ?? 0 + : album.Assets?.Count ?? 0 + }; + /// /// Maps an Album object to an AlbumFullDto object. /// @@ -31,21 +70,4 @@ public static class AlbumMapper { }).ToList() }; - /// - /// Maps an Album object to an AlbumPreviewDto object. - /// - /// The album to map. - /// An AlbumPreviewDto object. - public static AlbumPreviewDto ToAlbumPreviewDto(this Album album) => new AlbumPreviewDto { - Id = album.Id, - Name = album.Title, - Owner = album.UserOwnerId, - Person = album.PersonOwnerId, - PersonName = album.PersonOwner?.Name, - CoverAssetId = album.CoverAssetId, - AssetCount = album.Assets?.Count ?? 0 - }; - - - } diff --git a/Lactose/Migrations/20260712160403_AddAssetsSearchVisibilityIndex.Designer.cs b/Lactose/Migrations/20260712160403_AddAssetsSearchVisibilityIndex.Designer.cs new file mode 100644 index 0000000..3cb0b58 --- /dev/null +++ b/Lactose/Migrations/20260712160403_AddAssetsSearchVisibilityIndex.Designer.cs @@ -0,0 +1,581 @@ +// +using System; +using System.Collections; +using Lactose.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Lactose.Migrations +{ + [DbContext(typeof(LactoseDbContext))] + [Migration("20260712160403_AddAssetsSearchVisibilityIndex")] + partial class AddAssetsSearchVisibilityIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlbumAsset", b => + { + b.Property("AlbumsId") + .HasColumnType("uuid"); + + b.Property("AssetsId") + .HasColumnType("uuid"); + + b.HasKey("AlbumsId", "AssetsId"); + + b.HasIndex("AssetsId"); + + b.ToTable("AlbumAsset"); + }); + + modelBuilder.Entity("AssetTag", b => + { + b.Property("AssetsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("AssetsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("AssetTag"); + }); + + modelBuilder.Entity("AssetUser", b => + { + b.Property("SharedAssetsId") + .HasColumnType("uuid"); + + b.Property("SharedWithId") + .HasColumnType("uuid"); + + b.HasKey("SharedAssetsId", "SharedWithId"); + + b.HasIndex("SharedWithId"); + + b.ToTable("AssetUser"); + }); + + modelBuilder.Entity("Lactose.Models.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CoverAssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PersonOwnerId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserOwnerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CoverAssetId"); + + b.HasIndex("PersonOwnerId"); + + b.HasIndex("UserOwnerId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Lactose.Models.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("real"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FolderId") + .HasColumnType("uuid"); + + b.Property("FrameRate") + .HasColumnType("real"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("bit(64)"); + + b.Property("IsPubliclyShared") + .HasColumnType("boolean"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("OriginalFilename") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("OriginalPath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("PreviewFormat") + .IsRequired() + .HasColumnType("VARCHAR(16)"); + + b.Property("PreviewPath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("PreviewSize") + .HasColumnType("integer"); + + b.Property("ResolutionHeight") + .HasColumnType("integer"); + + b.Property("ResolutionWidth") + .HasColumnType("integer"); + + b.Property("ThumbnailFormat") + .IsRequired() + .HasColumnType("VARCHAR(16)"); + + b.Property("ThumbnailPath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("ThumbnailSize") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("OriginalPath") + .IsUnique(); + + b.HasIndex("OwnerId"); + + b.HasIndex("IsPubliclyShared", "OwnerId") + .HasDatabaseName("IX_Assets_VisibleForSearch") + .HasFilter("\"DeletedAt\" IS NULL"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Lactose.Models.Face", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("BoundingBoxX1") + .HasColumnType("integer"); + + b.Property("BoundingBoxX2") + .HasColumnType("integer"); + + b.Property("BoundingBoxY1") + .HasColumnType("integer"); + + b.Property("BoundingBoxY2") + .HasColumnType("integer"); + + b.Property("ImageHeight") + .HasColumnType("integer"); + + b.Property("ImageWidth") + .HasColumnType("integer"); + + b.Property("PersonId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("PersonId"); + + b.ToTable("Faces"); + }); + + modelBuilder.Entity("Lactose.Models.Folder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("BasePath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("RegexPattern") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.ToTable("Folders"); + }); + + modelBuilder.Entity("Lactose.Models.JobRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Finished") + .HasColumnType("timestamp with time zone"); + + b.Property("JobType") + .HasColumnType("integer"); + + b.Property("Message") + .HasColumnType("VARCHAR(2048)"); + + b.Property("ModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("ParentJobId") + .HasColumnType("uuid"); + + b.Property("Progress") + .HasColumnType("real"); + + b.Property("Started") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("JobRecords"); + }); + + modelBuilder.Entity("Lactose.Models.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("ProfileAssetId") + .HasColumnType("uuid"); + + b.Property("ProfileCropX") + .HasColumnType("real"); + + b.Property("ProfileCropY") + .HasColumnType("real"); + + b.Property("ProfileCropZoom") + .HasColumnType("real"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProfileAssetId"); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Lactose.Models.Setting", b => + { + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DisplayType") + .HasColumnType("integer"); + + b.PrimitiveCollection("Options") + .HasColumnType("text[]"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Value") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Name"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Lactose.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Lactose.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessLevel") + .HasColumnType("integer"); + + b.Property("BannedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("VARCHAR(128)"); + + b.Property("LastLogin") + .HasColumnType("timestamp with time zone"); + + b.Property("Password") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("RefreshTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("VARCHAR(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("AlbumAsset", b => + { + b.HasOne("Lactose.Models.Album", null) + .WithMany() + .HasForeignKey("AlbumsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lactose.Models.Asset", null) + .WithMany() + .HasForeignKey("AssetsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AssetTag", b => + { + b.HasOne("Lactose.Models.Asset", null) + .WithMany() + .HasForeignKey("AssetsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lactose.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AssetUser", b => + { + b.HasOne("Lactose.Models.Asset", null) + .WithMany() + .HasForeignKey("SharedAssetsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lactose.Models.User", null) + .WithMany() + .HasForeignKey("SharedWithId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Lactose.Models.Album", b => + { + b.HasOne("Lactose.Models.Asset", "CoverAsset") + .WithMany() + .HasForeignKey("CoverAssetId"); + + b.HasOne("Lactose.Models.Person", "PersonOwner") + .WithMany("Albums") + .HasForeignKey("PersonOwnerId"); + + b.HasOne("Lactose.Models.User", "UserOwner") + .WithMany("OwnedAlbums") + .HasForeignKey("UserOwnerId"); + + b.Navigation("CoverAsset"); + + b.Navigation("PersonOwner"); + + b.Navigation("UserOwner"); + }); + + modelBuilder.Entity("Lactose.Models.Asset", b => + { + b.HasOne("Lactose.Models.Folder", "Folder") + .WithMany("Assets") + .HasForeignKey("FolderId"); + + b.HasOne("Lactose.Models.User", "Owner") + .WithMany("OwnedAssets") + .HasForeignKey("OwnerId"); + + b.Navigation("Folder"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Lactose.Models.Face", b => + { + b.HasOne("Lactose.Models.Asset", "Asset") + .WithMany("Faces") + .HasForeignKey("AssetId"); + + b.HasOne("Lactose.Models.Person", "Person") + .WithMany("Faces") + .HasForeignKey("PersonId"); + + b.Navigation("Asset"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Lactose.Models.Person", b => + { + b.HasOne("Lactose.Models.Asset", "ProfileAsset") + .WithMany() + .HasForeignKey("ProfileAssetId"); + + b.Navigation("ProfileAsset"); + }); + + modelBuilder.Entity("Lactose.Models.Tag", b => + { + b.HasOne("Lactose.Models.Tag", "Parent") + .WithMany() + .HasForeignKey("ParentId"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Lactose.Models.Asset", b => + { + b.Navigation("Faces"); + }); + + modelBuilder.Entity("Lactose.Models.Folder", b => + { + b.Navigation("Assets"); + }); + + modelBuilder.Entity("Lactose.Models.Person", b => + { + b.Navigation("Albums"); + + b.Navigation("Faces"); + }); + + modelBuilder.Entity("Lactose.Models.User", b => + { + b.Navigation("OwnedAlbums"); + + b.Navigation("OwnedAssets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Lactose/Migrations/20260712160403_AddAssetsSearchVisibilityIndex.cs b/Lactose/Migrations/20260712160403_AddAssetsSearchVisibilityIndex.cs new file mode 100644 index 0000000..0820be2 --- /dev/null +++ b/Lactose/Migrations/20260712160403_AddAssetsSearchVisibilityIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lactose.Migrations +{ + /// + public partial class AddAssetsSearchVisibilityIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Assets_VisibleForSearch", + table: "Assets", + columns: new[] { "IsPubliclyShared", "OwnerId" }, + filter: "\"DeletedAt\" IS NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Assets_VisibleForSearch", + table: "Assets"); + } + } +} diff --git a/Lactose/Migrations/20260712162406_AddTrigramIndexes.Designer.cs b/Lactose/Migrations/20260712162406_AddTrigramIndexes.Designer.cs new file mode 100644 index 0000000..909d910 --- /dev/null +++ b/Lactose/Migrations/20260712162406_AddTrigramIndexes.Designer.cs @@ -0,0 +1,594 @@ +// +using System; +using System.Collections; +using Lactose.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Lactose.Migrations +{ + [DbContext(typeof(LactoseDbContext))] + [Migration("20260712162406_AddTrigramIndexes")] + partial class AddTrigramIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlbumAsset", b => + { + b.Property("AlbumsId") + .HasColumnType("uuid"); + + b.Property("AssetsId") + .HasColumnType("uuid"); + + b.HasKey("AlbumsId", "AssetsId"); + + b.HasIndex("AssetsId"); + + b.ToTable("AlbumAsset"); + }); + + modelBuilder.Entity("AssetTag", b => + { + b.Property("AssetsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("AssetsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("AssetTag"); + }); + + modelBuilder.Entity("AssetUser", b => + { + b.Property("SharedAssetsId") + .HasColumnType("uuid"); + + b.Property("SharedWithId") + .HasColumnType("uuid"); + + b.HasKey("SharedAssetsId", "SharedWithId"); + + b.HasIndex("SharedWithId"); + + b.ToTable("AssetUser"); + }); + + modelBuilder.Entity("Lactose.Models.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CoverAssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PersonOwnerId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserOwnerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CoverAssetId"); + + b.HasIndex("PersonOwnerId"); + + b.HasIndex("Title") + .HasDatabaseName("IX_Albums_Title_Trgm"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" }); + + b.HasIndex("UserOwnerId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Lactose.Models.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("real"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FolderId") + .HasColumnType("uuid"); + + b.Property("FrameRate") + .HasColumnType("real"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("bit(64)"); + + b.Property("IsPubliclyShared") + .HasColumnType("boolean"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("OriginalFilename") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("OriginalPath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("PreviewFormat") + .IsRequired() + .HasColumnType("VARCHAR(16)"); + + b.Property("PreviewPath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("PreviewSize") + .HasColumnType("integer"); + + b.Property("ResolutionHeight") + .HasColumnType("integer"); + + b.Property("ResolutionWidth") + .HasColumnType("integer"); + + b.Property("ThumbnailFormat") + .IsRequired() + .HasColumnType("VARCHAR(16)"); + + b.Property("ThumbnailPath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("ThumbnailSize") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("OriginalPath") + .IsUnique(); + + b.HasIndex("OwnerId"); + + b.HasIndex("IsPubliclyShared", "OwnerId") + .HasDatabaseName("IX_Assets_VisibleForSearch") + .HasFilter("\"DeletedAt\" IS NULL"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Lactose.Models.Face", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("BoundingBoxX1") + .HasColumnType("integer"); + + b.Property("BoundingBoxX2") + .HasColumnType("integer"); + + b.Property("BoundingBoxY1") + .HasColumnType("integer"); + + b.Property("BoundingBoxY2") + .HasColumnType("integer"); + + b.Property("ImageHeight") + .HasColumnType("integer"); + + b.Property("ImageWidth") + .HasColumnType("integer"); + + b.Property("PersonId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("PersonId"); + + b.ToTable("Faces"); + }); + + modelBuilder.Entity("Lactose.Models.Folder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("BasePath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("RegexPattern") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.ToTable("Folders"); + }); + + modelBuilder.Entity("Lactose.Models.JobRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Finished") + .HasColumnType("timestamp with time zone"); + + b.Property("JobType") + .HasColumnType("integer"); + + b.Property("Message") + .HasColumnType("VARCHAR(2048)"); + + b.Property("ModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("ParentJobId") + .HasColumnType("uuid"); + + b.Property("Progress") + .HasColumnType("real"); + + b.Property("Started") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("JobRecords"); + }); + + modelBuilder.Entity("Lactose.Models.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("ProfileAssetId") + .HasColumnType("uuid"); + + b.Property("ProfileCropX") + .HasColumnType("real"); + + b.Property("ProfileCropY") + .HasColumnType("real"); + + b.Property("ProfileCropZoom") + .HasColumnType("real"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .HasDatabaseName("IX_People_Name_Trgm"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" }); + + b.HasIndex("ProfileAssetId"); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Lactose.Models.Setting", b => + { + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DisplayType") + .HasColumnType("integer"); + + b.PrimitiveCollection("Options") + .HasColumnType("text[]"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Value") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Name"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Lactose.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Lactose.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessLevel") + .HasColumnType("integer"); + + b.Property("BannedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("VARCHAR(128)"); + + b.Property("LastLogin") + .HasColumnType("timestamp with time zone"); + + b.Property("Password") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("RefreshTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("VARCHAR(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("AlbumAsset", b => + { + b.HasOne("Lactose.Models.Album", null) + .WithMany() + .HasForeignKey("AlbumsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lactose.Models.Asset", null) + .WithMany() + .HasForeignKey("AssetsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AssetTag", b => + { + b.HasOne("Lactose.Models.Asset", null) + .WithMany() + .HasForeignKey("AssetsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lactose.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AssetUser", b => + { + b.HasOne("Lactose.Models.Asset", null) + .WithMany() + .HasForeignKey("SharedAssetsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lactose.Models.User", null) + .WithMany() + .HasForeignKey("SharedWithId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Lactose.Models.Album", b => + { + b.HasOne("Lactose.Models.Asset", "CoverAsset") + .WithMany() + .HasForeignKey("CoverAssetId"); + + b.HasOne("Lactose.Models.Person", "PersonOwner") + .WithMany("Albums") + .HasForeignKey("PersonOwnerId"); + + b.HasOne("Lactose.Models.User", "UserOwner") + .WithMany("OwnedAlbums") + .HasForeignKey("UserOwnerId"); + + b.Navigation("CoverAsset"); + + b.Navigation("PersonOwner"); + + b.Navigation("UserOwner"); + }); + + modelBuilder.Entity("Lactose.Models.Asset", b => + { + b.HasOne("Lactose.Models.Folder", "Folder") + .WithMany("Assets") + .HasForeignKey("FolderId"); + + b.HasOne("Lactose.Models.User", "Owner") + .WithMany("OwnedAssets") + .HasForeignKey("OwnerId"); + + b.Navigation("Folder"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Lactose.Models.Face", b => + { + b.HasOne("Lactose.Models.Asset", "Asset") + .WithMany("Faces") + .HasForeignKey("AssetId"); + + b.HasOne("Lactose.Models.Person", "Person") + .WithMany("Faces") + .HasForeignKey("PersonId"); + + b.Navigation("Asset"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Lactose.Models.Person", b => + { + b.HasOne("Lactose.Models.Asset", "ProfileAsset") + .WithMany() + .HasForeignKey("ProfileAssetId"); + + b.Navigation("ProfileAsset"); + }); + + modelBuilder.Entity("Lactose.Models.Tag", b => + { + b.HasOne("Lactose.Models.Tag", "Parent") + .WithMany() + .HasForeignKey("ParentId"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Lactose.Models.Asset", b => + { + b.Navigation("Faces"); + }); + + modelBuilder.Entity("Lactose.Models.Folder", b => + { + b.Navigation("Assets"); + }); + + modelBuilder.Entity("Lactose.Models.Person", b => + { + b.Navigation("Albums"); + + b.Navigation("Faces"); + }); + + modelBuilder.Entity("Lactose.Models.User", b => + { + b.Navigation("OwnedAlbums"); + + b.Navigation("OwnedAssets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Lactose/Migrations/20260712162406_AddTrigramIndexes.cs b/Lactose/Migrations/20260712162406_AddTrigramIndexes.cs new file mode 100644 index 0000000..be2f7dd --- /dev/null +++ b/Lactose/Migrations/20260712162406_AddTrigramIndexes.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lactose.Migrations +{ + /// + public partial class AddTrigramIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:pg_trgm", ",,") + .Annotation("Npgsql:PostgresExtension:vector", ",,") + .OldAnnotation("Npgsql:PostgresExtension:vector", ",,"); + + migrationBuilder.CreateIndex( + name: "IX_People_Name_Trgm", + table: "People", + column: "Name") + .Annotation("Npgsql:IndexMethod", "gin") + .Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" }); + + migrationBuilder.CreateIndex( + name: "IX_Albums_Title_Trgm", + table: "Albums", + column: "Title") + .Annotation("Npgsql:IndexMethod", "gin") + .Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_People_Name_Trgm", + table: "People"); + + migrationBuilder.DropIndex( + name: "IX_Albums_Title_Trgm", + table: "Albums"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:vector", ",,") + .OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,") + .OldAnnotation("Npgsql:PostgresExtension:vector", ",,"); + } + } +} diff --git a/Lactose/Migrations/LactoseDbContextModelSnapshot.cs b/Lactose/Migrations/LactoseDbContextModelSnapshot.cs index 392f1db..e979af5 100644 --- a/Lactose/Migrations/LactoseDbContextModelSnapshot.cs +++ b/Lactose/Migrations/LactoseDbContextModelSnapshot.cs @@ -21,6 +21,7 @@ namespace Lactose.Migrations .HasAnnotation("ProductVersion", "10.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 63); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -100,6 +101,12 @@ namespace Lactose.Migrations b.HasIndex("PersonOwnerId"); + b.HasIndex("Title") + .HasDatabaseName("IX_Albums_Title_Trgm"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" }); + b.HasIndex("UserOwnerId"); b.ToTable("Albums"); @@ -194,6 +201,10 @@ namespace Lactose.Migrations b.HasIndex("OwnerId"); + b.HasIndex("IsPubliclyShared", "OwnerId") + .HasDatabaseName("IX_Assets_VisibleForSearch") + .HasFilter("\"DeletedAt\" IS NULL"); + b.ToTable("Assets"); }); @@ -330,6 +341,12 @@ namespace Lactose.Migrations b.HasKey("Id"); + b.HasIndex("Name") + .HasDatabaseName("IX_People_Name_Trgm"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" }); + b.HasIndex("ProfileAssetId"); b.ToTable("People"); diff --git a/Lactose/Repositories/AlbumRepository.cs b/Lactose/Repositories/AlbumRepository.cs index 3988e5b..3c3bd2e 100644 --- a/Lactose/Repositories/AlbumRepository.cs +++ b/Lactose/Repositories/AlbumRepository.cs @@ -1,4 +1,7 @@ +using Butter.Dtos.Album; +using Butter.Types; using Lactose.Context; +using Lactose.Mapper; using Lactose.Models; namespace Lactose.Repositories; @@ -23,28 +26,59 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository { } /// - public IEnumerable SearchQuery(string query, int page = 0, int pageSize = 150, string? sortBy = null, bool sortAsc = false, bool unassigned = false) { - IQueryable albumsQuery = context.Albums - .Include(a => a.CoverAsset) - .Include(a => a.PersonOwner) - .Include(a => a.Assets)!.ThenInclude(a => a.SharedWith) - .Where(x => x.Title.Contains(query)); + public IEnumerable SearchQuery(string query, int page = 0, int pageSize = 150, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User) { + IQueryable albumsQuery = context.Albums; + + if (!string.IsNullOrEmpty(query)) + albumsQuery = albumsQuery.Where(x => EF.Functions.ILike(x.Title, $"%{query}%")); if (unassigned) albumsQuery = albumsQuery.Where(a => a.PersonOwnerId == null); IOrderedQueryable ordered = sortBy?.ToLower() switch { - "name" => sortAsc ? albumsQuery.OrderBy(a => a.Title) : albumsQuery.OrderByDescending(a => a.Title), - "updated" => sortAsc ? albumsQuery.OrderBy(a => a.UpdatedAt) : albumsQuery.OrderByDescending(a => a.UpdatedAt), - "assets" => sortAsc ? albumsQuery.OrderBy(a => a.Assets.Count) : albumsQuery.OrderByDescending(a => a.Assets.Count), - "person" => sortAsc ? albumsQuery.OrderBy(a => a.PersonOwner!.Name): albumsQuery.OrderByDescending(a => a.PersonOwner!.Name), - _ => sortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt), + "name" => sortAsc ? albumsQuery.OrderBy(a => a.Title) : albumsQuery.OrderByDescending(a => a.Title), + "updated" => sortAsc ? albumsQuery.OrderBy(a => a.UpdatedAt) : albumsQuery.OrderByDescending(a => a.UpdatedAt), + "assets" => sortAsc + ? albumsQuery.OrderBy(a => a.Assets!.Count) + : albumsQuery.OrderByDescending(a => a.Assets!.Count), + "person" => sortAsc ? albumsQuery.OrderBy(a => a.PersonOwner!.Name) : albumsQuery.OrderByDescending(a => a.PersonOwner!.Name), + _ => sortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt), }; - return ordered - .Skip(page * pageSize) - .Take(pageSize); + var pageOffset = page * pageSize; + var albumIds = ordered + .Skip(pageOffset) + .Take(pageSize) + .Select(a => a.Id) + .ToList(); + + if (albumIds.Count == 0) + return []; + + // Load with includes for client-side visibility filtering + var pagedAlbums = context.Albums + .Include(a => a.Assets)!.ThenInclude(a => a.SharedWith) + .Include(a => a.PersonOwner) + .Where(a => albumIds.Contains(a.Id)) + .ToList(); + + // Filter out albums with no visible content for regular users + if (accessLevel == EAccessLevel.User) + pagedAlbums = pagedAlbums.Where(a => + a.Assets != null && a.Assets.Any(asset => + asset.DeletedAt == null && ( + asset.IsPubliclyShared || + asset.OwnerId == userId || + (asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId)) + )) + ).ToList(); + + // Project to DTOs + var dtos = pagedAlbums.Select(a => a.ToAlbumPreviewDto(userId, accessLevel)).ToList(); + + var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i); + return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))]; } /// diff --git a/Lactose/Repositories/AssetRepository.cs b/Lactose/Repositories/AssetRepository.cs index bf93921..c5aa63b 100644 --- a/Lactose/Repositories/AssetRepository.cs +++ b/Lactose/Repositories/AssetRepository.cs @@ -20,7 +20,7 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository { /// public IEnumerable FindByDateRange(DateTime from, DateTime to, int pageNumber, int pageSize) => context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to) - .Skip((pageNumber - 1) * pageSize) + .Skip(pageNumber * pageSize) .Take(pageSize); /// @@ -150,7 +150,7 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository { var seedHash = seed.Value.GetHashCode(); var shuffledIds = allIds .OrderBy(id => DeterministicHash(id, seedHash)) - .Skip((pageNumber - 1) * pageSize) + .Skip(pageNumber * pageSize) .Take(pageSize) .ToList(); @@ -166,7 +166,7 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository { dataQuery = orderRandomly ? dataQuery.OrderBy(a => a.Id) : dataQuery.OrderByDescending(a => a.CreatedAt); return dataQuery - .Skip((pageNumber - 1) * pageSize) + .Skip(pageNumber * pageSize) .Take(pageSize) .ToList(); } diff --git a/Lactose/Repositories/IAlbumRepository.cs b/Lactose/Repositories/IAlbumRepository.cs index 4c43fac..042956f 100644 --- a/Lactose/Repositories/IAlbumRepository.cs +++ b/Lactose/Repositories/IAlbumRepository.cs @@ -1,3 +1,5 @@ +using Butter.Dtos.Album; +using Butter.Types; using Lactose.Models; namespace Lactose.Repositories; @@ -72,7 +74,7 @@ public interface IAlbumRepository : IDisposable { public void Remove(Album album); /// - /// Searches for albums based on a query string with optional sorting and filtering. + /// Searches for albums based on a query string with optional sorting, filtering, and access-level scoping. /// /// The search query string to filter by title. /// The page number for pagination (default is 0). @@ -80,7 +82,9 @@ public interface IAlbumRepository : IDisposable { /// The field to sort by (e.g. "name", "created", "updated", "assets", "person"). When null, defaults to created descending. /// Whether to sort ascending. Default is false (descending). /// When true, only return albums with no person assigned. - /// A list of albums that match the search query. - public IEnumerable SearchQuery(string query, int page = 0, int pageSize = 150, string? sortBy = null, bool sortAsc = false, bool unassigned = false); + /// The current user's ID for access-level filtering. + /// The current user's access level. Regular users only see albums with visible assets. + /// A list of album previews matching the search query. + public IEnumerable SearchQuery(string query, int page = 0, int pageSize = 150, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User); } \ No newline at end of file diff --git a/Lactose/Repositories/IAssetRepository.cs b/Lactose/Repositories/IAssetRepository.cs index aab628d..c4ab288 100644 --- a/Lactose/Repositories/IAssetRepository.cs +++ b/Lactose/Repositories/IAssetRepository.cs @@ -167,7 +167,7 @@ public interface IAssetRepository : IDisposable { /// Optional end date for the date range filter. /// If true, orders randomly instead of by date. /// An optional seed for deterministic random ordering across paginated requests. - /// The 1-based page number. + /// The zero-based page number. /// The number of items per page. /// The total count of matching assets. /// A paginated collection of assets matching the filters. diff --git a/Lactose/Repositories/IPersonRepository.cs b/Lactose/Repositories/IPersonRepository.cs index af7988d..d73cd0f 100644 --- a/Lactose/Repositories/IPersonRepository.cs +++ b/Lactose/Repositories/IPersonRepository.cs @@ -1,3 +1,4 @@ +using Butter.Dtos.Person; using Lactose.Models; namespace Lactose.Repositories; @@ -64,8 +65,8 @@ public interface IPersonRepository : IDisposable { /// Whether to sort ascending. Default is true. /// The current user's ID for access-level filtering. /// The current user's access level. - /// A paginated list of people matching the query. - IEnumerable SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User); + /// A paginated list of person previews matching the query. + IEnumerable SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User); /// /// Finds multiple people by their IDs. diff --git a/Lactose/Repositories/JobRecordRepository.cs b/Lactose/Repositories/JobRecordRepository.cs index d76da4a..ddaba92 100644 --- a/Lactose/Repositories/JobRecordRepository.cs +++ b/Lactose/Repositories/JobRecordRepository.cs @@ -35,7 +35,7 @@ public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepositor return query .OrderByDescending(j => j.Finished ?? j.Created) - .Skip((page - 1) * pageSize) + .Skip(page * pageSize) .Take(pageSize) .ToList(); } diff --git a/Lactose/Repositories/PersonRepository.cs b/Lactose/Repositories/PersonRepository.cs index 30ae9bd..ee070f9 100644 --- a/Lactose/Repositories/PersonRepository.cs +++ b/Lactose/Repositories/PersonRepository.cs @@ -1,3 +1,4 @@ +using Butter.Dtos.Person; using Butter.Types; using Lactose.Context; using Lactose.Models; @@ -29,18 +30,16 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository { if (accessLevel != EAccessLevel.User) return GetAll(); - var all = context.People - .Include(p => p.Albums)! - .ThenInclude(a => a.Assets)! - .ThenInclude(a => a.SharedWith) + return context.People + .Include(p => p.Albums) + .Where(p => p.Albums!.Any(a => + a.Assets!.Any(asset => asset.DeletedAt == null && ( + asset.IsPubliclyShared || + asset.OwnerId == userId || + asset.SharedWith!.Any(u => u.Id == userId) + )) + )) .ToList(); - - return all.Where(p => p.Albums != null && p.Albums.Any(a => - a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && ( - asset.IsPubliclyShared || - asset.OwnerId == userId || - (asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId)) - )))); } /// @@ -59,35 +58,71 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository { public void Remove(Person person) => context.People.Remove(person); /// - public IEnumerable SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User) { - IQueryable peopleQuery = context.People - .Include(p => p.Albums); + public IEnumerable SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User) { + IQueryable peopleQuery = context.People; - // For regular users, only show people who have at least one album with visible assets - if (accessLevel == EAccessLevel.User) { - peopleQuery = peopleQuery.Where(p => p.Albums != null && p.Albums.Any(a => - a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && ( - asset.IsPubliclyShared || - asset.OwnerId == userId || - (asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId)) - )))); - } - - // Apply search filter if (!string.IsNullOrEmpty(query)) - peopleQuery = peopleQuery.Where(p => p.Name.Contains(query)); + peopleQuery = peopleQuery.Where(p => EF.Functions.ILike(p.Name, $"%{query}%")); - // Apply sorting + // Apply sorting — use subquery counts for "albums" IOrderedQueryable ordered = sortBy?.ToLower() switch { - "created" => sortAsc ? peopleQuery.OrderBy(p => p.CreatedAt) : peopleQuery.OrderByDescending(p => p.CreatedAt), - "albums" => sortAsc ? peopleQuery.OrderBy(p => p.Albums.Count) : peopleQuery.OrderByDescending(p => p.Albums.Count), - _ => sortAsc ? peopleQuery.OrderBy(p => p.Name) : peopleQuery.OrderByDescending(p => p.Name), + "created" => sortAsc ? peopleQuery.OrderBy(p => p.CreatedAt) : peopleQuery.OrderByDescending(p => p.CreatedAt), + "albums" => sortAsc + ? peopleQuery.OrderBy(p => p.Albums!.Count) + : peopleQuery.OrderByDescending(p => p.Albums!.Count), + _ => sortAsc ? peopleQuery.OrderBy(p => p.Name) : peopleQuery.OrderByDescending(p => p.Name), }; - return ordered - .Skip(page * pageSize) - .Take(pageSize); + // Two-step pagination: first get the IDs in the correct order + var pageOffset = page * pageSize; + var personIds = ordered + .Skip(pageOffset) + .Take(pageSize) + .Select(p => p.Id) + .ToList(); + + if (personIds.Count == 0) + return []; + + // Load with includes for client-side visibility filtering + var pagedPeople = context.People + .Include(p => p.Albums)!.ThenInclude(a => a.Assets)!.ThenInclude(a => a.SharedWith) + .Where(p => personIds.Contains(p.Id)) + .ToList(); + + // Filter out people with no visible content for regular users + if (accessLevel == EAccessLevel.User) + pagedPeople = pagedPeople.Where(p => + p.Albums != null && p.Albums.Any(a => + a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && ( + asset.IsPubliclyShared || + asset.OwnerId == userId || + (asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId)) + ))) + ).ToList(); + + // Project to DTOs + var dtos = pagedPeople.Select(p => new PersonPreviewDto { + Id = p.Id, + Name = p.Name, + ProfileAssetId = p.ProfileAssetId, + ProfileCropX = p.ProfileCropX, + ProfileCropY = p.ProfileCropY, + ProfileCropZoom = p.ProfileCropZoom, + TotalAlbums = accessLevel == EAccessLevel.User + ? p.Albums?.Count(a => + a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && ( + asset.IsPubliclyShared || + asset.OwnerId == userId || + (asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId)) + ))) ?? 0 + : p.Albums?.Count ?? 0 + }).ToList(); + + // Reorder to match the paginated order + var orderMap = personIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i); + return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))]; } /// diff --git a/Lactose/Repositories/StatsRepository.cs b/Lactose/Repositories/StatsRepository.cs index a682fda..a7d5b33 100644 --- a/Lactose/Repositories/StatsRepository.cs +++ b/Lactose/Repositories/StatsRepository.cs @@ -64,47 +64,52 @@ public class StatsRepository(LactoseDbContext context, ISettingsRepository setti _ = int.TryParse(previewSizeSetting?.Value, out var expectedPreviewSize); dto.AssetsMissingThumbnail = context.Assets.Count(a => - (a.ThumbnailPath == "") && a.DeletedAt == null); + (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null); dto.AssetsMissingThumbnailStale = expectedThumbnailSize > 0 ? context.Assets.Count(a => - a.ThumbnailPath != "" && a.ThumbnailSize != expectedThumbnailSize && a.DeletedAt == null) + a.ThumbnailPath != null && a.ThumbnailPath != "" && a.ThumbnailSize != expectedThumbnailSize && a.DeletedAt == null) : 0; dto.AssetsMissingPreviews = context.Assets.Count(a => - (a.PreviewPath == "") && a.DeletedAt == null); + (a.PreviewPath == null || a.PreviewPath == "") && a.DeletedAt == null); dto.AssetsMissingPreviewsStale = expectedPreviewSize > 0 ? context.Assets.Count(a => - a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize && a.DeletedAt == null) + a.PreviewPath != null && a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize && a.DeletedAt == null) : 0; var emptyHash = new BitArray(64); dto.AssetsMissingPhash = context.Assets.Count(a => a.Hash == emptyHash && a.DeletedAt == null); + var albumAssetSet = context.Set>("AlbumAsset"); + dto.AssetsWithNoAlbum = context.Assets.Count(a => - a.DeletedAt == null && !(a.Albums ?? Enumerable.Empty()).Any()); + a.DeletedAt == null && !albumAssetSet.Any(aa => EF.Property(aa, "AssetsId") == a.Id)); dto.AssetsWithNoPerson = context.Assets.Count(a => - a.DeletedAt == null && (a.Albums ?? Enumerable.Empty()).All(al => al.PersonOwnerId == null)); + a.DeletedAt == null && !albumAssetSet.Any(aa => + EF.Property(aa, "AssetsId") == a.Id + && context.Albums.Any(al => al.Id == EF.Property(aa, "AlbumsId") && al.PersonOwnerId != null))); dto.AlbumsMissingCover = context.Albums.Count(a => a.CoverAssetId == null); dto.CosplayersMissingCover = 0; - dto.TopTags = context.Assets - .Where(a => a.DeletedAt == null) - .SelectMany(a => a.Tags ?? Enumerable.Empty()) - .GroupBy(t => new { t.Id, t.Name }) - .Select(g => new TagStatDto { + dto.TopTags = ( + from at in context.Set>("AssetTag") + join a in context.Assets.Where(a => a.DeletedAt == null) + on EF.Property(at, "AssetsId") equals (Guid?)a.Id + join t in context.Tags + on EF.Property(at, "TagsId") equals (Guid?)t.Id + group t by new { t.Id, t.Name } into g + select new TagStatDto { Id = g.Key.Id, Name = g.Key.Name, AssetCount = g.Count() - }) - .OrderByDescending(t => t.AssetCount) - .Take(10) - .ToList(); + } + ).OrderByDescending(t => t.AssetCount).Take(10).ToList(); var resolutions = context.Assets .Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0) diff --git a/Lactose/WepApiTest.http b/Lactose/WepApiTest.http index 2b4d343..c69e5ff 100644 --- a/Lactose/WepApiTest.http +++ b/Lactose/WepApiTest.http @@ -1,478 +1,925 @@ -@WepApiTest_HostAddress = http://localhost:5162 - -### -# @name Register rng user -< {% - client.global.set("rngUser", $random.alphabetic(15)); - client.global.set("rngEmail", $random.alphabetic(5) + "@redcode.com"); - client.global.set("rngPassword", "test" + $random.alphabetic(5)); -%} - -POST {{WepApiTest_HostAddress}}/api/auth/register -Content-Type: application/json - -{ - "username": "{{rngUser}}", - "email": "{{rngEmail}}", - "password": "{{rngPassword}}" -} - -> {% - client.test("Register rng user", function () { - client.assert(response.status === 200) - }); -%} - -### Login rng user - -POST {{WepApiTest_HostAddress}}/api/auth/login -Content-Type: application/json - -{ - "identifier": "{{rngUser}}", - "password": "{{rngPassword}}" -} - -> {% - client.global.set("auth_token", jsonPath(response.body, "$.token")); - client.test("Login rng user", function () { - client.assert(response.status === 200) - //client.assert(jsonPath(response.body, "$.success") != true,jsonPath(response.body, "$.errorMessage")??"null") - }); -%} - - -### API Authenticated Empty (wrong route should 404) -POST {{WepApiTest_HostAddress}}/api/auth -Content-Type: application/json - -{} - -> {% - client.test("API Authenticated Empty", function () { - client.assert(response.status === 404) - }); -%} - -### API Rng User Authenticated (correct route) -POST {{WepApiTest_HostAddress}}/api/auth/login -Authorization: Bearer {{auth_token}} -Accept: application/json -Content-Type: application/json - -{"identifier": "{{rngUser}}", "password": "{{rngPassword}}"} - -> {% - client.test("API Rng User Authenticated", function () { - client.assert(response.status === 200) - }); -%} - -### API Logout -POST {{WepApiTest_HostAddress}}/api/auth/logout -Authorization: Bearer {{auth_token}} -Accept: application/json - -> {% - client.test("API Logout", function () { - client.assert(response.status === 200) - }); -%} - -### API Login as admin -POST {{WepApiTest_HostAddress}}/api/auth/login -Content-Type: application/json - -{ - "identifier": "admin", - "password": "admin" -} - -> {% - client.global.set("admin_token", jsonPath(response.body, "$.token")); - client.test("Login as admin", function () { - client.assert(response.status === 200) - //client.assert(jsonPath(response.body, "$.success") != true,jsonPath(response.body, "$.errorMessage")??"null") - }); -%} - -### API Authenticated as admin -POST {{WepApiTest_HostAddress}}/api/auth/login -Authorization: Bearer {{admin_token}} -Accept: application/json -Content-Type: application/json - -{"identifier": "admin", "password": "admin"} - -> {% - client.test("API Authenticated as admin", function () { - client.assert(response.status === 200) - }); -%} - -### Try User create as rng user -PUT {{WepApiTest_HostAddress}}/api/user -Authorization: Bearer {{auth_token}} -Content-Type: application/json - -{ - "username": "thisUserShouldNotBeCreated", - "email": "nope@nope.cope", - "password": "qwertyu", - "accessLevel": 0 -} - -> {% - client.test("User create as rng user", function () { - client.assert(response.status === 403) - }); -%} - -### Try User create as admin -PUT {{WepApiTest_HostAddress}}/api/user -Authorization: Bearer {{admin_token}} -Accept: application/json -Content-Type: application/json - -{ - "username": "thisUserShouldBeCreated", - "email": "thisUserWillBeDeleted@gmai.com", - "password": "qwertyu", - "accessLevel": 1 -} - -> {% - client.global.set("createdUser", jsonPath(response.body, "$")); - client.test("User create as admin", function () { - client.assert(response.status === 200) - }); -%} - -### Try get the user as rng user -GET {{WepApiTest_HostAddress}}/api/user/{{createdUser}} -Authorization: Bearer {{auth_token}} -Accept: application/json - -//TODO: check what data is returned -> {% - client.test("Get the user as rng user", function () { - client.assert(response.status === 200) - }); -%} - -### Try get the user as admin -GET {{WepApiTest_HostAddress}}/api/user/{{createdUser}} -Authorization: Bearer {{admin_token}} -Accept: application/json - -//TODO: check what data is returned -> {% - client.test("Get the user as admin", function () { - client.assert(response.status === 200) - }); -%} - -### Try Delete user as rng user -DELETE {{WepApiTest_HostAddress}}/api/user/{{createdUser}} -Authorization: Bearer {{auth_token}} - -> {% - client.test("Delete user as rng user", function () { - client.assert(response.status === 403) - }); -%} - -### Delete created user -DELETE {{WepApiTest_HostAddress}}/api/user/{{createdUser}} -Authorization: Bearer {{admin_token}} - -> {% - client.test("Delete created user", function () { - client.assert(response.status === 200) - }); -%} - -### Get all people as rng user -GET {{WepApiTest_HostAddress}}/api/person -Authorization: Bearer {{auth_token}} -Accept: application/json - -> {% - client.global.set("personId", jsonPath(response.body, "$[0].id")); - client.test("Get all people as rng user", function () { - client.assert(response.status === 200) - }); -%} - -### Get all people as admin -GET {{WepApiTest_HostAddress}}/api/person -Authorization: Bearer {{admin_token}} -Accept: application/json - -> {% - client.test("Get all people as admin", function () { - client.assert(response.status === 200) - }); -%} - -### Get person by ID as rng user -GET {{WepApiTest_HostAddress}}/api/person/{{personId}} -Authorization: Bearer {{auth_token}} -Accept: application/json - -> {% - client.test("Get person by ID as rng user", function () { - client.assert(response.status === 200 || response.status === 404) - }); -%} - -### Try update person as rng user (should be 403) -POST {{WepApiTest_HostAddress}}/api/person/{{personId}} -Authorization: Bearer {{auth_token}} -Content-Type: application/json - -{ - "name": "hacker_attempt", - "profileAssetId": null -} - -> {% - client.test("Try update person as rng user", function () { - client.assert(response.status === 403) - }); -%} - -### Try delete person as rng user (should be 403) -DELETE {{WepApiTest_HostAddress}}/api/person/{{personId}} -Authorization: Bearer {{auth_token}} - -> {% - client.test("Try delete person as rng user", function () { - client.assert(response.status === 403) - }); -%} - -### Create person as rng user (should be 403) -PUT {{WepApiTest_HostAddress}}/api/person -Authorization: Bearer {{auth_token}} -Content-Type: application/json - -{ - "name": "hacker_create", - "profileAssetId": null -} - -> {% - client.test("Create person as rng user", function () { - client.assert(response.status === 403) - }); -%} - -### Create person as admin -PUT {{WepApiTest_HostAddress}}/api/person -Authorization: Bearer {{admin_token}} -Content-Type: application/json - -{ - "name": "Test Cosplayer Created" -} - -> {% - client.global.set("createdPersonId", jsonPath(response.body, "$")); - client.test("Create person as admin", function () { - client.assert(response.status === 200) - client.assert(jsonPath(response.body, "$") != null) - }); -%} - -### Update created person name as admin -POST {{WepApiTest_HostAddress}}/api/person/{{createdPersonId}} -Authorization: Bearer {{admin_token}} -Content-Type: application/json - -{ - "name": "Updated via API Test", - "profileAssetId": null -} - -> {% - client.test("Update created person name as admin", function () { - client.assert(response.status === 200) - }); -%} - -### Get created person by ID as admin -GET {{WepApiTest_HostAddress}}/api/person/{{createdPersonId}} -Authorization: Bearer {{admin_token}} -Accept: application/json - -> {% - client.test("Get created person by ID as admin", function () { - client.assert(response.status === 200) - client.assert(jsonPath(response.body, "$.name") === "Updated via API Test") - }); -%} - -### Get non-existent person (should be 404) -GET {{WepApiTest_HostAddress}}/api/person/00000000-0000-0000-0000-000000000000 -Authorization: Bearer {{admin_token}} -Accept: application/json - -> {% - client.test("Get non-existent person", function () { - client.assert(response.status === 404) - }); -%} - -### Update non-existent person as admin (should be 404) -POST {{WepApiTest_HostAddress}}/api/person/00000000-0000-0000-0000-000000000000 -Authorization: Bearer {{admin_token}} -Content-Type: application/json - -{ - "name": "ghost" -} - -> {% - client.test("Update non-existent person", function () { - client.assert(response.status === 404) - }); -%} - -### Try bulk update person as rng user (should be 403) -POST {{WepApiTest_HostAddress}}/api/person -Authorization: Bearer {{auth_token}} -Content-Type: application/json - -{ - "ids": ["{{personId}}"], - "data": { "name": "hacker_bulk" } -} - -> {% - client.test("Bulk update person as rng user", function () { - client.assert(response.status === 403) - }); -%} - -### Bulk update created person as admin -POST {{WepApiTest_HostAddress}}/api/person -Authorization: Bearer {{admin_token}} -Content-Type: application/json - -{ - "ids": ["{{createdPersonId}}"], - "data": { "name": "Bulk Updated Person" } -} - -> {% - client.test("Bulk update person as admin", function () { - client.assert(response.status === 200) - }); -%} - -### Try bulk delete person as rng user (should be 403) -DELETE {{WepApiTest_HostAddress}}/api/person -Authorization: Bearer {{auth_token}} -Content-Type: application/json - -["{{createdPersonId}}"] - -> {% - client.test("Bulk delete person as rng user", function () { - client.assert(response.status === 403) - }); -%} - -### Create test album as admin -PUT {{WepApiTest_HostAddress}}/api/album -Authorization: Bearer {{admin_token}} -Content-Type: application/json - -{ - "name": "Test Album for E2E" -} - -> {% - client.global.set("testAlbumId", jsonPath(response.body, "$")); - client.test("Create test album as admin", function () { - client.assert(response.status === 200) - client.assert(jsonPath(response.body, "$") != null) - }); -%} - -### Assign test album to created person -POST {{WepApiTest_HostAddress}}/api/album/{{testAlbumId}} -Authorization: Bearer {{admin_token}} -Content-Type: application/json - -{ - "person": "{{createdPersonId}}" -} - -> {% - client.test("Assign album to person", function () { - client.assert(response.status === 200) - }); -%} - -### Update test album without removePerson field (should not affect person link) -POST {{WepApiTest_HostAddress}}/api/album/{{testAlbumId}} -Authorization: Bearer {{admin_token}} -Content-Type: application/json - -{ - "name": "Test Album Updated" -} - -> {% - client.test("Update album without removePerson", function () { - client.assert(response.status === 200) - }); -%} - -### Remove person assignment from test album -POST {{WepApiTest_HostAddress}}/api/album/{{testAlbumId}} -Authorization: Bearer {{admin_token}} -Content-Type: application/json - -{ - "removePerson": true -} - -> {% - client.test("Unlink person from album", function () { - client.assert(response.status === 200) - }); -%} - -### Delete test album -DELETE {{WepApiTest_HostAddress}}/api/album/{{testAlbumId}} -Authorization: Bearer {{admin_token}} - -> {% - client.test("Delete test album", function () { - client.assert(response.status === 200) - }); -%} - -### Delete created person as admin -DELETE {{WepApiTest_HostAddress}}/api/person/{{createdPersonId}} -Authorization: Bearer {{admin_token}} - -> {% - client.test("Delete created person as admin", function () { - client.assert(response.status === 200) - }); -%} - -### Verify created person is gone -GET {{WepApiTest_HostAddress}}/api/person/{{createdPersonId}} -Authorization: Bearer {{admin_token}} -Accept: application/json - -> {% - client.test("Verify created person is deleted", function () { - client.assert(response.status === 404) - }); -%} +@WepApiTest_HostAddress = http://localhost:5162 + +# ============================================================================= +# SETUP — Create three users with distinct roles +# ============================================================================= + +### 1. Login as seeded admin +POST {{WepApiTest_HostAddress}}/api/auth/login +Content-Type: application/json + +{ + "identifier": "admin", + "password": "admin" +} + +> {% + client.global.set("admin_token", jsonPath(response.body, "$.token")); + client.global.set("admin_id", jsonPath(response.body, "$.userId")); + client.test("Login as seeded admin", function () { + client.assert(response.status === 200) + }); +%} + +### 2. Create curator user (admin only) +< {% + client.global.set("curatorName", $random.alphabetic(15)); + client.global.set("curatorPwd", "test" + $random.alphabetic(5)); +%} + +PUT {{WepApiTest_HostAddress}}/api/user +Authorization: Bearer {{admin_token}} +Content-Type: application/json + +{ + "username": "{{curatorName}}", + "email": "{{curatorName}}@redcode.com", + "password": "{{curatorPwd}}", + "accessLevel": 1 +} + +> {% + client.global.set("curator_id", jsonPath(response.body, "$")); + client.test("Create curator user as admin", function () { + client.assert(response.status === 200) + }); +%} + +### 3. Login as curator +POST {{WepApiTest_HostAddress}}/api/auth/login +Content-Type: application/json + +{ + "identifier": "{{curatorName}}", + "password": "{{curatorPwd}}" +} + +> {% + client.global.set("curator_token", jsonPath(response.body, "$.token")); + client.test("Login as curator", function () { + client.assert(response.status === 200) + }); +%} + +### 4. Register regular user +< {% + client.global.set("userName", $random.alphabetic(15)); + client.global.set("userEmail", $random.alphabetic(5) + "@redcode.com"); + client.global.set("userPwd", "test" + $random.alphabetic(5)); +%} + +POST {{WepApiTest_HostAddress}}/api/auth/register +Content-Type: application/json + +{ + "username": "{{userName}}", + "email": "{{userEmail}}", + "password": "{{userPwd}}" +} + +> {% + client.test("Register regular user", function () { + client.assert(response.status === 200) + }); +%} + +### 5. Login as regular user +POST {{WepApiTest_HostAddress}}/api/auth/login +Content-Type: application/json + +{ + "identifier": "{{userName}}", + "password": "{{userPwd}}" +} + +> {% + client.global.set("user_token", jsonPath(response.body, "$.token")); + client.global.set("user_refresh", jsonPath(response.body, "$.refreshToken")); + client.global.set("user_id", jsonPath(response.body, "$.userId")); + client.test("Login as regular user", function () { + client.assert(response.status === 200) + }); +%} + +# ============================================================================= +# AUTH TESTS +# ============================================================================= + +### 6. Wrong auth route should 404 +POST {{WepApiTest_HostAddress}}/api/auth +Content-Type: application/json + +{} + +> {% + client.test("Wrong auth route returns 404", function () { + client.assert(response.status === 404) + }); +%} + +### 7. Login with valid token (re-authentication) +POST {{WepApiTest_HostAddress}}/api/auth/login +Authorization: Bearer {{user_token}} +Content-Type: application/json + +{"identifier": "{{userName}}", "password": "{{userPwd}}"} + +> {% + client.test("Re-authenticate with valid token", function () { + client.assert(response.status === 200) + }); +%} + +### 8. Logout +POST {{WepApiTest_HostAddress}}/api/auth/logout +Authorization: Bearer {{user_token}} + +> {% + client.test("Logout", function () { + client.assert(response.status === 200) + }); +%} + +### 9. Re-login user after logout +POST {{WepApiTest_HostAddress}}/api/auth/login +Content-Type: application/json + +{ + "identifier": "{{userName}}", + "password": "{{userPwd}}" +} + +> {% + client.global.set("user_token", jsonPath(response.body, "$.token")); + client.global.set("user_refresh", jsonPath(response.body, "$.refreshToken")); + client.global.set("user_id", jsonPath(response.body, "$.userId")); + client.test("Re-login user after logout", function () { + client.assert(response.status === 200) + }); +%} + +### 10. Refresh token with empty body (should be 400) +POST {{WepApiTest_HostAddress}}/api/auth/refresh +Content-Type: application/json + +{ + "userId": "", + "refreshToken": "" +} + +> {% + client.test("Refresh token with empty body returns 400", function () { + client.assert(response.status === 400) + }); +%} + +### 11. Refresh token with valid data +POST {{WepApiTest_HostAddress}}/api/auth/refresh +Content-Type: application/json + +{ + "userId": "{{user_id}}", + "refreshToken": "{{user_refresh}}" +} + +> {% + client.global.set("user_token", jsonPath(response.body, "$.token")); + client.test("Refresh token with valid data", function () { + client.assert(response.status === 200) + client.assert(jsonPath(response.body, "$.token") != null) + }); +%} + +# ============================================================================= +# USER CRUD TESTS +# ============================================================================= + +### 12. Get all users as admin (should succeed) +GET {{WepApiTest_HostAddress}}/api/user?page=0&pageSize=5 +Authorization: Bearer {{admin_token}} + +> {% + client.test("Get all users as admin", function () { + client.assert(response.status === 200) + }); +%} + +### 13. Get all users as regular user (should 403) +GET {{WepApiTest_HostAddress}}/api/user +Authorization: Bearer {{user_token}} + +> {% + client.test("Get all users as regular user should 403", function () { + client.assert(response.status === 403) + }); +%} + +### 14. Get all users as curator (should 403) +GET {{WepApiTest_HostAddress}}/api/user +Authorization: Bearer {{curator_token}} + +> {% + client.test("Get all users as curator should 403", function () { + client.assert(response.status === 403) + }); +%} + +### 15. Create user as regular user (should 403) +< {% + client.global.set("userCreateAttempt", $random.alphabetic(10)); +%} + +PUT {{WepApiTest_HostAddress}}/api/user +Authorization: Bearer {{user_token}} +Content-Type: application/json + +{ + "username": "{{userCreateAttempt}}", + "email": "{{userCreateAttempt}}@redcode.com", + "password": "qwertyu", + "accessLevel": 0 +} + +> {% + client.test("Create user as regular user should 403", function () { + client.assert(response.status === 403) + }); +%} + +### 16. Create user as curator (should 403) +< {% + client.global.set("curatorCreateAttempt", $random.alphabetic(10)); +%} + +PUT {{WepApiTest_HostAddress}}/api/user +Authorization: Bearer {{curator_token}} +Content-Type: application/json + +{ + "username": "{{curatorCreateAttempt}}", + "email": "{{curatorCreateAttempt}}@redcode.com", + "password": "test1234", + "accessLevel": 0 +} + +> {% + client.test("Create user as curator should 403", function () { + client.assert(response.status === 403) + }); +%} + +### 17. Get own user profile as regular user +GET {{WepApiTest_HostAddress}}/api/user/{{user_id}} +Authorization: Bearer {{user_token}} + +> {% + client.test("Get own profile as regular user", function () { + client.assert(response.status === 200) + }); +%} + +### 18. Update own profile as regular user +POST {{WepApiTest_HostAddress}}/api/user/update +Authorization: Bearer {{user_token}} +Content-Type: application/json + +{ + "id": "{{user_id}}", + "username": "{{userName}}" +} + +> {% + client.test("Update own profile as regular user", function () { + client.assert(response.status === 200) + }); +%} + +# ============================================================================= +# PERSON TESTS +# ============================================================================= + +### 19. Create person as regular user (should 403) +PUT {{WepApiTest_HostAddress}}/api/person +Authorization: Bearer {{user_token}} +Content-Type: application/json + +{ + "name": "hacker_create" +} + +> {% + client.test("Create person as regular user should 403", function () { + client.assert(response.status === 403) + }); +%} + +### 20. Create person as curator (should succeed) +< {% + client.global.set("curatorPerson", $random.alphabetic(15)); +%} + +PUT {{WepApiTest_HostAddress}}/api/person +Authorization: Bearer {{curator_token}} +Content-Type: application/json + +{ + "name": "{{curatorPerson}}" +} + +> {% + client.global.set("curatorPersonId", jsonPath(response.body, "$")); + client.test("Create person as curator", function () { + client.assert(response.status === 200) + }); +%} + +### 21. Delete person as curator (cleanup) +DELETE {{WepApiTest_HostAddress}}/api/person/{{curatorPersonId}} +Authorization: Bearer {{curator_token}} + +> {% + client.test("Delete person as curator", function () { + client.assert(response.status === 200) + }); +%} + +### 22. Create test person as admin +PUT {{WepApiTest_HostAddress}}/api/person +Authorization: Bearer {{admin_token}} +Content-Type: application/json + +{ + "name": "Test Cosplayer Created" +} + +> {% + client.global.set("testPersonId", jsonPath(response.body, "$")); + client.test("Create test person as admin", function () { + client.assert(response.status === 200) + client.assert(jsonPath(response.body, "$") != null) + }); +%} + +### 23. Get all people as anonymous +GET {{WepApiTest_HostAddress}}/api/person?page=0&pageSize=5 + +> {% + client.test("Get all people as anonymous", function () { + client.assert(response.status === 200) + }); +%} + +### 24. Get all people as regular user +GET {{WepApiTest_HostAddress}}/api/person?page=0&pageSize=5 +Authorization: Bearer {{user_token}} + +> {% + client.test("Get all people as regular user", function () { + client.assert(response.status === 200) + }); +%} + +### 25. Get all people as curator +GET {{WepApiTest_HostAddress}}/api/person?page=0&pageSize=5 +Authorization: Bearer {{curator_token}} + +> {% + client.test("Get all people as curator", function () { + client.assert(response.status === 200) + }); +%} + +### 26. Get all people as admin +GET {{WepApiTest_HostAddress}}/api/person?page=0&pageSize=5 +Authorization: Bearer {{admin_token}} + +> {% + client.test("Get all people as admin", function () { + client.assert(response.status === 200) + }); +%} + +### 27. Get test person by ID as admin +GET {{WepApiTest_HostAddress}}/api/person/{{testPersonId}} +Authorization: Bearer {{admin_token}} + +> {% + client.test("Get test person by ID as admin", function () { + client.assert(response.status === 200) + client.assert(jsonPath(response.body, "$.name") === "Test Cosplayer Created") + }); +%} + +### 28. Get non-existent person (should 404) +GET {{WepApiTest_HostAddress}}/api/person/00000000-0000-0000-0000-000000000000 +Authorization: Bearer {{admin_token}} + +> {% + client.test("Get non-existent person", function () { + client.assert(response.status === 404) + }); +%} + +### 29. Update person as regular user (should 403) +POST {{WepApiTest_HostAddress}}/api/person/{{testPersonId}} +Authorization: Bearer {{user_token}} +Content-Type: application/json + +{ + "name": "hacker_attempt" +} + +> {% + client.test("Update person as regular user should 403", function () { + client.assert(response.status === 403) + }); +%} + +### 30. Update person as curator (should succeed) +POST {{WepApiTest_HostAddress}}/api/person/{{testPersonId}} +Authorization: Bearer {{curator_token}} +Content-Type: application/json + +{ + "name": "Updated by curator" +} + +> {% + client.test("Update person as curator", function () { + client.assert(response.status === 200) + }); +%} + +### 31. Update person as admin +POST {{WepApiTest_HostAddress}}/api/person/{{testPersonId}} +Authorization: Bearer {{admin_token}} +Content-Type: application/json + +{ + "name": "Updated via API Test" +} + +> {% + client.test("Update person as admin", function () { + client.assert(response.status === 200) + }); +%} + +### 32. Verify person name update +GET {{WepApiTest_HostAddress}}/api/person/{{testPersonId}} +Authorization: Bearer {{admin_token}} + +> {% + client.test("Verify person name update", function () { + client.assert(response.status === 200) + client.assert(jsonPath(response.body, "$.name") === "Updated via API Test") + }); +%} + +### 33. Update non-existent person (should 404) +POST {{WepApiTest_HostAddress}}/api/person/00000000-0000-0000-0000-000000000000 +Authorization: Bearer {{admin_token}} +Content-Type: application/json + +{ + "name": "ghost" +} + +> {% + client.test("Update non-existent person", function () { + client.assert(response.status === 404) + }); +%} + +### 34. Delete person as regular user (should 403) +DELETE {{WepApiTest_HostAddress}}/api/person/{{testPersonId}} +Authorization: Bearer {{user_token}} + +> {% + client.test("Delete person as regular user should 403", function () { + client.assert(response.status === 403) + }); +%} + +### 35. Bulk update person as regular user (should 403) +POST {{WepApiTest_HostAddress}}/api/person +Authorization: Bearer {{user_token}} +Content-Type: application/json + +{ + "ids": ["{{testPersonId}}"], + "data": { "name": "hacker_bulk" } +} + +> {% + client.test("Bulk update person as regular user should 403", function () { + client.assert(response.status === 403) + }); +%} + +### 36. Bulk update person as admin +POST {{WepApiTest_HostAddress}}/api/person +Authorization: Bearer {{admin_token}} +Content-Type: application/json + +{ + "ids": ["{{testPersonId}}"], + "data": { "name": "Bulk Updated Person" } +} + +> {% + client.test("Bulk update person as admin", function () { + client.assert(response.status === 200) + }); +%} + +### 37. Bulk delete person as regular user (should 403) +DELETE {{WepApiTest_HostAddress}}/api/person +Authorization: Bearer {{user_token}} +Content-Type: application/json + +["{{testPersonId}}"] + +> {% + client.test("Bulk delete person as regular user should 403", function () { + client.assert(response.status === 403) + }); +%} + +# ============================================================================= +# ALBUM TESTS +# ============================================================================= + +### 38. Create album as regular user (should 403) +< {% + client.global.set("albumHack", $random.alphabetic(10)); +%} + +PUT {{WepApiTest_HostAddress}}/api/album +Authorization: Bearer {{user_token}} +Content-Type: application/json + +{ + "name": "{{albumHack}}" +} + +> {% + client.test("Create album as regular user should 403", function () { + client.assert(response.status === 403) + }); +%} + +### 39. Create album as curator (should succeed) +< {% + client.global.set("curatorAlbum", $random.alphabetic(15)); +%} + +PUT {{WepApiTest_HostAddress}}/api/album +Authorization: Bearer {{curator_token}} +Content-Type: application/json + +{ + "name": "{{curatorAlbum}}" +} + +> {% + client.global.set("curatorAlbumId", jsonPath(response.body, "$")); + client.test("Create album as curator", function () { + client.assert(response.status === 200) + }); +%} + +### 40. Delete album as curator (cleanup) +DELETE {{WepApiTest_HostAddress}}/api/album/{{curatorAlbumId}} +Authorization: Bearer {{curator_token}} + +> {% + client.test("Delete album as curator", function () { + client.assert(response.status === 200) + }); +%} + +### 41. Create test album as admin +PUT {{WepApiTest_HostAddress}}/api/album +Authorization: Bearer {{admin_token}} +Content-Type: application/json + +{ + "name": "Test Album for E2E" +} + +> {% + client.global.set("testAlbumId", jsonPath(response.body, "$")); + client.test("Create test album as admin", function () { + client.assert(response.status === 200) + client.assert(jsonPath(response.body, "$") != null) + }); +%} + +### 42. Assign test album to test person +POST {{WepApiTest_HostAddress}}/api/album/{{testAlbumId}} +Authorization: Bearer {{admin_token}} +Content-Type: application/json + +{ + "person": "{{testPersonId}}" +} + +> {% + client.test("Assign album to person", function () { + client.assert(response.status === 200) + }); +%} + +### 43. Update album without removePerson field (should keep person link) +POST {{WepApiTest_HostAddress}}/api/album/{{testAlbumId}} +Authorization: Bearer {{admin_token}} +Content-Type: application/json + +{ + "name": "Test Album Updated" +} + +> {% + client.test("Update album without removePerson", function () { + client.assert(response.status === 200) + }); +%} + +### 44. Remove person assignment from album +POST {{WepApiTest_HostAddress}}/api/album/{{testAlbumId}} +Authorization: Bearer {{admin_token}} +Content-Type: application/json + +{ + "removePerson": true +} + +> {% + client.test("Unlink person from album", function () { + client.assert(response.status === 200) + }); +%} + +### 45. Get all albums as anonymous +GET {{WepApiTest_HostAddress}}/api/album?page=0&pageSize=5 + +> {% + client.test("Get all albums as anonymous", function () { + client.assert(response.status === 200) + }); +%} + +### 46. Get all albums as regular user +GET {{WepApiTest_HostAddress}}/api/album?page=0&pageSize=5 +Authorization: Bearer {{user_token}} + +> {% + client.test("Get all albums as regular user", function () { + client.assert(response.status === 200) + }); +%} + +### 47. Get all albums as curator +GET {{WepApiTest_HostAddress}}/api/album?page=0&pageSize=5 +Authorization: Bearer {{curator_token}} + +> {% + client.test("Get all albums as curator", function () { + client.assert(response.status === 200) + }); +%} + +### 48. Get all albums as admin +GET {{WepApiTest_HostAddress}}/api/album?page=0&pageSize=5 +Authorization: Bearer {{admin_token}} + +> {% + client.test("Get all albums as admin", function () { + client.assert(response.status === 200) + }); +%} + +### 49. Search albums as admin +GET {{WepApiTest_HostAddress}}/api/album?search=Test&pageSize=5 +Authorization: Bearer {{admin_token}} + +> {% + client.test("Search albums as admin", function () { + client.assert(response.status === 200) + }); +%} + +### 50. Get album by ID as admin +GET {{WepApiTest_HostAddress}}/api/album/{{testAlbumId}} +Authorization: Bearer {{admin_token}} + +> {% + client.test("Get album by ID as admin", function () { + client.assert(response.status === 200) + client.assert(jsonPath(response.body, "$.id") != null) + }); +%} + +### 51. Get non-existent album (should 404) +GET {{WepApiTest_HostAddress}}/api/album/00000000-0000-0000-0000-000000000000 +Authorization: Bearer {{admin_token}} + +> {% + client.test("Get non-existent album", function () { + client.assert(response.status === 404) + }); +%} + +### 52. Delete test album as admin +DELETE {{WepApiTest_HostAddress}}/api/album/{{testAlbumId}} +Authorization: Bearer {{admin_token}} + +> {% + client.test("Delete test album", function () { + client.assert(response.status === 200) + }); +%} + +# ============================================================================= +# TAG TESTS +# ============================================================================= + +### 53. Search tags as admin +GET {{WepApiTest_HostAddress}}/api/tag?search=test&pageSize=5 +Authorization: Bearer {{admin_token}} + +> {% + client.test("Search tags as admin", function () { + client.assert(response.status === 200) + }); +%} + +### 54. Create tag as regular user (should 403) +PUT {{WepApiTest_HostAddress}}/api/tag +Authorization: Bearer {{user_token}} +Content-Type: application/json + +{ + "name": "hacker-tag" +} + +> {% + client.test("Create tag as regular user should 403", function () { + client.assert(response.status === 403) + }); +%} + +### 55. Create tag as curator (should succeed) +< {% + client.global.set("e2eTag", $random.alphabetic(10)); +%} + +PUT {{WepApiTest_HostAddress}}/api/tag +Authorization: Bearer {{curator_token}} +Content-Type: application/json + +{ + "name": "e2e-{{e2eTag}}" +} + +> {% + client.test("Create tag as curator", function () { + client.assert(response.status === 200) + }); +%} + +# ============================================================================= +# ASSET TESTS +# ============================================================================= + +### 56. Search assets as regular user +GET {{WepApiTest_HostAddress}}/api/asset?page=0&pageSize=5 +Authorization: Bearer {{user_token}} + +> {% + client.test("Search assets as regular user", function () { + client.assert(response.status === 200) + }); +%} + +### 57. Search assets as anonymous +GET {{WepApiTest_HostAddress}}/api/asset?page=0&pageSize=5 + +> {% + client.test("Search assets as anonymous", function () { + client.assert(response.status === 200) + }); +%} + +# ============================================================================= +# STATS TESTS +# ============================================================================= + +### 58. Get stats as admin +GET {{WepApiTest_HostAddress}}/api/stats +Authorization: Bearer {{admin_token}} + +> {% + client.test("Get stats as admin", function () { + client.assert(response.status === 200) + }); +%} + +### 59. Get stats as curator +GET {{WepApiTest_HostAddress}}/api/stats +Authorization: Bearer {{curator_token}} + +> {% + client.test("Get stats as curator", function () { + client.assert(response.status === 200) + }); +%} + +### 60. Get stats as regular user (should 403) +GET {{WepApiTest_HostAddress}}/api/stats +Authorization: Bearer {{user_token}} + +> {% + client.test("Get stats as regular user should 403", function () { + client.assert(response.status === 403) + }); +%} + +# ============================================================================= +# SETTINGS TESTS +# ============================================================================= + +### 61. Get settings as admin +GET {{WepApiTest_HostAddress}}/api/settings +Authorization: Bearer {{admin_token}} + +> {% + client.test("Get settings as admin", function () { + client.assert(response.status === 200) + }); +%} + +### 62. Get settings as curator (should 403) +GET {{WepApiTest_HostAddress}}/api/settings +Authorization: Bearer {{curator_token}} + +> {% + client.test("Get settings as curator should 403", function () { + client.assert(response.status === 403) + }); +%} + +### 63. Get settings as regular user (should 403) +GET {{WepApiTest_HostAddress}}/api/settings +Authorization: Bearer {{user_token}} + +> {% + client.test("Get settings as regular user should 403", function () { + client.assert(response.status === 403) + }); +%} + +# ============================================================================= +# CLEANUP +# ============================================================================= + +### 64. Delete test person as admin +DELETE {{WepApiTest_HostAddress}}/api/person/{{testPersonId}} +Authorization: Bearer {{admin_token}} + +> {% + client.test("Delete test person as admin", function () { + client.assert(response.status === 200) + }); +%} + +### 65. Verify test person is deleted +GET {{WepApiTest_HostAddress}}/api/person/{{testPersonId}} +Authorization: Bearer {{admin_token}} + +> {% + client.test("Verify test person is deleted", function () { + client.assert(response.status === 404) + }); +%} + +### 66. Delete curator user as admin +DELETE {{WepApiTest_HostAddress}}/api/user/{{curator_id}} +Authorization: Bearer {{admin_token}} + +> {% + client.test("Delete curator user as admin", function () { + client.assert(response.status === 200) + }); +%} + +### 67. Delete regular user as admin +DELETE {{WepApiTest_HostAddress}}/api/user/{{user_id}} +Authorization: Bearer {{admin_token}} + +> {% + client.test("Delete regular user as admin", function () { + client.assert(response.status === 200) + }); +%} diff --git a/Lactose/http-client.env.json b/Lactose/http-client.env.json index fd2bb2f..3fa7391 100644 --- a/Lactose/http-client.env.json +++ b/Lactose/http-client.env.json @@ -1,6 +1,8 @@ { "dev": { - "rngUser": "preFilledValue", - "rngPassword": "preFilledValue" + "userName": "preFilledValue", + "userPwd": "preFilledValue", + "curatorName": "preFilledValue", + "curatorPwd": "preFilledValue" } } \ No newline at end of file diff --git a/MilkStream.Client/Components/Layout/NavMenu.razor b/MilkStream.Client/Components/Layout/NavMenu.razor index d3afd3d..2897766 100644 --- a/MilkStream.Client/Components/Layout/NavMenu.razor +++ b/MilkStream.Client/Components/Layout/NavMenu.razor @@ -35,15 +35,7 @@ } else {
-
- - -
+
- @if (allAlbums.Count > 0) { + @if (allAlbumsDict.Count > 0) { if (selectMode) {