diff --git a/src/Cove.Api/Program.cs b/src/Cove.Api/Program.cs index 6c932dfa..3b886423 100644 --- a/src/Cove.Api/Program.cs +++ b/src/Cove.Api/Program.cs @@ -14,6 +14,7 @@ using Serilog.Events; using Cove.Api.Hubs; using Cove.Api.Services; +using Cove.Core.Common; using Cove.Core.Entities.Galleries; using Cove.Core.Events; using Cove.Core.Interfaces; @@ -59,6 +60,23 @@ static bool IsWeakJwtSecret(string? secret) static string GenerateJwtSecret() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); +// Copies the canonical wire configuration from CoveJson.Default onto a framework-provided options +// object. Framework hooks (MVC, SignalR, Http.Json) each expose their OWN JsonSerializerOptions that +// must be configured in place — the frozen CoveJson.Default cannot be assigned by reference — so the +// naming policy, combined type-info resolver, and EVERY converter are copied across. Copying the +// converter list is what carries the enum behavior: the global JsonStringEnumConverter(CamelCase) is +// the string-enum mechanism on the reflection-fallback path (it does not travel via the source-gen +// context's UseStringEnumConverter policy), and the type-specific CriterionModifierJsonConverter is +// preserved ahead of it (first-match-wins ordering is inherited from CoveJson.Default). +static void ApplyCanonicalJson(System.Text.Json.JsonSerializerOptions options) +{ + options.PropertyNamingPolicy = CoveJson.Default.PropertyNamingPolicy; + options.PropertyNameCaseInsensitive = CoveJson.Default.PropertyNameCaseInsensitive; + options.TypeInfoResolver = CoveJson.Default.TypeInfoResolver; + foreach (var converter in CoveJson.Default.Converters) + options.Converters.Add(converter); +} + static LogEventLevel ParseSerilogLogLevel(string? level) => level?.Trim().ToLowerInvariant() switch { @@ -391,10 +409,7 @@ LIMIT 1 // SignalR builder.Services.AddSignalR() - .AddJsonProtocol(options => - { - options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - }); + .AddJsonProtocol(options => ApplyCanonicalJson(options.PayloadSerializerOptions)); // Auth var authConfig = coveConfig.GetSection("Auth"); @@ -438,10 +453,12 @@ LIMIT 1 options.Filters.Add(); options.Filters.Add(); }) - .AddJsonOptions(options => - { - options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - }); + .AddJsonOptions(options => ApplyCanonicalJson(options.JsonSerializerOptions)); + + // Minimal-API + extension endpoint serialization. Extension MapEndpoints responses (and any + // extension code writing via HttpContext.Response.WriteAsJsonAsync) inherit these options; before + // this registration the path fell back to bare web defaults and emitted enums as integers. + builder.Services.ConfigureHttpJsonOptions(options => ApplyCanonicalJson(options.SerializerOptions)); builder.Services.AddOpenApi(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); diff --git a/src/Cove.Api/Services/ConfigService.cs b/src/Cove.Api/Services/ConfigService.cs index 87217719..e516a090 100644 --- a/src/Cove.Api/Services/ConfigService.cs +++ b/src/Cove.Api/Services/ConfigService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Cove.Core.Common; using Cove.Core.DTOs; using Cove.Core.Interfaces; @@ -15,12 +16,12 @@ public class ConfigService private readonly CoveConfiguration _config; private readonly ILogger _logger; private readonly string _configPath; - private readonly JsonSerializerOptions _jsonOpts = new() + // Config file already stores camelCase properties and enum strings, so the canonical options + // produce byte-compatible output; WriteIndented preserves the on-disk file shape. + private readonly JsonSerializerOptions _jsonOpts = new(CoveJson.Default) { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, }; private readonly SemaphoreSlim _lock = new(1, 1); diff --git a/src/Cove.Api/Services/DynamicGroups.cs b/src/Cove.Api/Services/DynamicGroups.cs index e077b093..dabf2e20 100644 --- a/src/Cove.Api/Services/DynamicGroups.cs +++ b/src/Cove.Api/Services/DynamicGroups.cs @@ -1,8 +1,8 @@ using System.Text.Json; -using System.Text.Json.Serialization; using System.Text.Json.Nodes; using System.Linq.Expressions; using Cove.Core.Auth; +using Cove.Core.Common; using Cove.Core.DTOs; using Cove.Core.Entities; using Cove.Core.Enums; @@ -522,11 +522,6 @@ protected async Task HydratePageAsync( public sealed class FilterDynamicGroupSource(CoveContext db, IVideoRepository videoRepository, IImageRepository imageRepository) : IDynamicGroupSource, IDynamicGroupCountingSource { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - Converters = { new CriterionModifierJsonConverter(), new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, - }; - public string Key => DynamicGroupResolver.FilterSourceKey; public string DisplayName => "Filtered Entities"; @@ -923,7 +918,7 @@ private static bool IsSupportedEntityType(string entityType) try { - return objectFilter.Value.Deserialize(JsonOptions); + return objectFilter.Value.Deserialize(CoveJson.Default); } catch (JsonException) { @@ -931,45 +926,6 @@ private static bool IsSupportedEntityType(string entityType) } } - private sealed class CriterionModifierJsonConverter : JsonConverter - { - public override CriterionModifier Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.String && TryParse(reader.GetString(), out var modifier)) - return modifier; - - if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var numeric) && Enum.IsDefined(typeof(CriterionModifier), numeric)) - return (CriterionModifier)numeric; - - throw new JsonException($"Invalid criterion modifier token '{reader.TokenType}'."); - } - - public override void Write(Utf8JsonWriter writer, CriterionModifier value, JsonSerializerOptions options) - => writer.WriteStringValue(JsonNamingPolicy.CamelCase.ConvertName(value.ToString())); - - private static bool TryParse(string? value, out CriterionModifier modifier) - { - modifier = default; - if (string.IsNullOrWhiteSpace(value)) - return false; - - var normalized = Normalize(value); - foreach (var name in Enum.GetNames()) - { - if (!string.Equals(Normalize(name), normalized, StringComparison.OrdinalIgnoreCase)) - continue; - - modifier = Enum.Parse(name); - return true; - } - - return false; - } - - private static string Normalize(string value) - => new(value.Where(char.IsLetterOrDigit).ToArray()); - } - private static string NormalizeEntityType(string? entityType) { var normalized = string.IsNullOrWhiteSpace(entityType) ? "video" : entityType.Trim().ToLowerInvariant(); @@ -1647,7 +1603,7 @@ private static FilterDynamicGroupQuery ParseQuery(string? queryJson) try { - return JsonSerializer.Deserialize(queryJson, JsonOptions) ?? new FilterDynamicGroupQuery(); + return JsonSerializer.Deserialize(queryJson, CoveJson.Default) ?? new FilterDynamicGroupQuery(); } catch (JsonException) { diff --git a/src/Cove.Api/Services/FieldProvenanceService.cs b/src/Cove.Api/Services/FieldProvenanceService.cs index f1bf256b..f503337a 100644 --- a/src/Cove.Api/Services/FieldProvenanceService.cs +++ b/src/Cove.Api/Services/FieldProvenanceService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Cove.Core.Common; using Cove.Core.DTOs; using Cove.Core.Entities; using Cove.Core.Interfaces; @@ -9,7 +10,9 @@ namespace Cove.Api.Services; public sealed class FieldProvenanceService(CoveContext db) : IFieldProvenanceService { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + // Dynamic audit blob (no fixed host enums); the canonical resolver's reflection fallback + // handles the runtime type passed to Serialize. + private static readonly JsonSerializerOptions JsonOptions = CoveJson.Default; public async Task RecordAsync( AffinityHostType hostType, diff --git a/src/Cove.Api/Services/ScrapeAttemptService.cs b/src/Cove.Api/Services/ScrapeAttemptService.cs index dcd9b239..c51f3d5a 100644 --- a/src/Cove.Api/Services/ScrapeAttemptService.cs +++ b/src/Cove.Api/Services/ScrapeAttemptService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text; +using Cove.Core.Common; using Cove.Core.DTOs; using Cove.Core.Entities; using Cove.Core.Interfaces; @@ -11,7 +12,8 @@ namespace Cove.Api.Services; public class ScrapeAttemptService(CoveContext db, ScraperService scraperService, IVideoCoverService videoCoverService, PerformerScrapeService performerScrapeService, ITagProvenanceService tagProvenanceService, IGroupMetadataApplyService groupMetadataApplyService, ILogger logger, IFieldProvenanceService? fieldProvenanceService = null) { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + // Persisted scrape blobs are dynamic Dictionary payloads with no host enums. + private static readonly JsonSerializerOptions JsonOptions = CoveJson.Default; private static string BuildScraperSourceKey(string? scraperId) => string.IsNullOrWhiteSpace(scraperId) ? "scraper" : $"scraper:{scraperId.Trim()}"; diff --git a/src/Cove.Api/Services/ScraperService.cs b/src/Cove.Api/Services/ScraperService.cs index 8a5a5849..b09bb0ad 100644 --- a/src/Cove.Api/Services/ScraperService.cs +++ b/src/Cove.Api/Services/ScraperService.cs @@ -1,4 +1,5 @@ using HtmlAgilityPack; +using Cove.Core.Common; using Cove.Core.DTOs; using Cove.Core.Interfaces; using Cove.Plugins; @@ -36,7 +37,7 @@ public class ScraperService private readonly Dictionary _extensionScraperCache = new(StringComparer.OrdinalIgnoreCase); private const string BuiltinScraperSourcePath = "builtin:cove.core.scrapers"; private static readonly Regex BracketTagRegex = new(@"\[[^\[\]\r\n]{1,80}\]", RegexOptions.Compiled); - private static readonly JsonSerializerOptions ExtensionScrapeJsonOptions = new(JsonSerializerDefaults.Web) + private static readonly JsonSerializerOptions ExtensionScrapeJsonOptions = new(CoveJson.Default) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; diff --git a/src/Cove.Core/Common/CoveJson.cs b/src/Cove.Core/Common/CoveJson.cs index 71f79210..3d205ad6 100644 --- a/src/Cove.Core/Common/CoveJson.cs +++ b/src/Cove.Core/Common/CoveJson.cs @@ -1,8 +1,55 @@ using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; namespace Cove.Core.Common; +/// +/// The single canonical for Cove's wire format. Every +/// serialization boundary (MVC, SignalR, minimal-API/extension endpoints, and manual +/// serialize/deserialize call sites) routes through this instance so the emitted shape is +/// consistent: camelCase properties, case-insensitive lenient reads, and camelCase named-string +/// enums (int or string accepted on read). +/// public static class CoveJson { - public static JsonSerializerOptions Default { get; } = new(JsonSerializerDefaults.Web); -} \ No newline at end of file + /// + /// Frozen canonical options. Combines the source-generated + /// (fast path for registered host DTOs) with a reflection resolver so runtime-loaded + /// extension types still serialize. Enums emit as camelCase strings via the global + /// converter — including reflection-fallback enums that the source-gen context does not + /// cover — while CriterionModifier keeps its type-specific lenient converter. + /// + public static JsonSerializerOptions Default { get; } = BuildDefault(); + + private static JsonSerializerOptions BuildDefault() + { + // Retain Web semantics: camelCase properties, case-insensitive read, AllowReadingFromString. + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + // Source-gen for registered host types; reflection fallback for everything else, + // including per-extension AssemblyLoadContext types that cannot be pre-registered. + TypeInfoResolver = JsonTypeInfoResolver.Combine( + CoveJsonContext.Default, + new DefaultJsonTypeInfoResolver()), + }; + + // Order matters. System.Text.Json evaluates the Converters collection in registration + // order and uses the FIRST converter whose CanConvert(type) is true (it does not rank by + // specificity). So the type-specific CriterionModifier converter MUST be registered before + // the global enum factory to win for CriterionModifier — otherwise the factory (which + // matches every enum) would intercept it and drop its extra read leniency + // (greater_than / GREATER_THAN separator-insensitive forms). + // (1) Type-specific converter: keeps CriterionModifier's lenient read; concrete + // JsonConverter only matches that one type. + // (2) Global string-enum policy: covers the reflection-fallback path (all extension-ALC + // enums and every unregistered host DTO enum); without it those enums would emit their + // default integer and defeat the canonical wire format. + options.Converters.Add(new CriterionModifierJsonConverter()); + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + + // Freeze once, after the resolver and both converters are set. + options.MakeReadOnly(); + return options; + } +} diff --git a/src/Cove.Core/Common/CoveJsonContext.cs b/src/Cove.Core/Common/CoveJsonContext.cs new file mode 100644 index 00000000..39168a55 --- /dev/null +++ b/src/Cove.Core/Common/CoveJsonContext.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; +using Cove.Core.Auth; +using Cove.Core.DTOs; + +namespace Cove.Core.Common; + +/// +/// Source-generated serializer context backing the canonical +/// options. Provides fast, generator-produced metadata for the registered host DTO roots; +/// anything not registered here degrades to the reflection resolver combined into +/// (it does not throw), so this list can grow incrementally. +/// +/// +/// UseStringEnumConverter = true is the fast path for enums reachable from the +/// registered graph only. Enums resolved via the +/// reflection fallback — every runtime-loaded extension type and every unregistered host +/// DTO enum — are governed by the global string-enum converter added to +/// , not by this attribute. +/// +/// +/// +/// Polymorphic wire types convention (for future use — no wire-polymorphic type exists today): +/// annotate a base type that is genuinely serialized as its base over the wire with +/// [JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] and string-valued +/// [JsonDerivedType(typeof(TDerived), "kind")] discriminators. Rules to respect: +/// the discriminator must appear first on read; it must NOT be declared required; +/// values must always be serialized through the base type (a manual +/// Serialize<TConcrete> drops the discriminator); and consider +/// JsonUnknownDerivedTypeHandling so newer derived types keep a discriminator when +/// round-tripped through an older reader. Do not annotate persistence-only or in-process +/// hierarchies that never cross the wire as their base type. +/// +/// +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, UseStringEnumConverter = true)] +[JsonSerializable(typeof(VideoDto))] +[JsonSerializable(typeof(PaginatedResponse))] +[JsonSerializable(typeof(CoveConfigDto))] +[JsonSerializable(typeof(UserUiPreferencesDto))] +public partial class CoveJsonContext : JsonSerializerContext +{ +} diff --git a/src/Cove.Core/Common/CriterionModifierJsonConverter.cs b/src/Cove.Core/Common/CriterionModifierJsonConverter.cs new file mode 100644 index 00000000..7cffa841 --- /dev/null +++ b/src/Cove.Core/Common/CriterionModifierJsonConverter.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Cove.Core.Interfaces; + +namespace Cove.Core.Common; + +/// +/// Type-specific converter for . Writes the camelCase named +/// string (identical to the shared string-enum policy), but reads more leniently: it accepts +/// separator-insensitive, case-insensitive forms (e.g. greater_than, GREATER_THAN, +/// greater than) in addition to the canonical camelCase string and the underlying integer. +/// +/// Registered on the canonical options ahead of the global string-enum converter; a type-specific +/// takes precedence, so keeps this +/// extra read leniency while every other enum flows through the global camelCase policy. The wire +/// names are public API — do not rename the members without keeping the emitted strings stable. +/// +public sealed class CriterionModifierJsonConverter : JsonConverter +{ + public override CriterionModifier Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String && TryParse(reader.GetString(), out var modifier)) + return modifier; + + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var numeric) && Enum.IsDefined(typeof(CriterionModifier), numeric)) + return (CriterionModifier)numeric; + + throw new JsonException($"Invalid criterion modifier token '{reader.TokenType}'."); + } + + public override void Write(Utf8JsonWriter writer, CriterionModifier value, JsonSerializerOptions options) + => writer.WriteStringValue(JsonNamingPolicy.CamelCase.ConvertName(value.ToString())); + + private static bool TryParse(string? value, out CriterionModifier modifier) + { + modifier = default; + if (string.IsNullOrWhiteSpace(value)) + return false; + + var normalized = Normalize(value); + foreach (var name in Enum.GetNames()) + { + if (!string.Equals(Normalize(name), normalized, StringComparison.OrdinalIgnoreCase)) + continue; + + modifier = Enum.Parse(name); + return true; + } + + return false; + } + + private static string Normalize(string value) + => new(value.Where(char.IsLetterOrDigit).ToArray()); +} diff --git a/src/Cove.Core/Enums/Enums.cs b/src/Cove.Core/Enums/Enums.cs index 90849e1e..5aefb506 100644 --- a/src/Cove.Core/Enums/Enums.cs +++ b/src/Cove.Core/Enums/Enums.cs @@ -1,21 +1,28 @@ +using System.Text.Json.Serialization; + namespace Cove.Core.Enums; +// Wire names below are public API (external/consumer-visible values); pin them so a future +// C# rename cannot change the serialized string. public enum GenderEnum { - Male, - Female, - TransgenderMale, - TransgenderFemale, - Intersex, - NonBinary + [JsonStringEnumMemberName("male")] Male, + [JsonStringEnumMemberName("female")] Female, + [JsonStringEnumMemberName("transgenderMale")] TransgenderMale, + [JsonStringEnumMemberName("transgenderFemale")] TransgenderFemale, + [JsonStringEnumMemberName("intersex")] Intersex, + [JsonStringEnumMemberName("nonBinary")] NonBinary } public enum CircumcisedEnum { - Cut, - Uncut + [JsonStringEnumMemberName("cut")] Cut, + [JsonStringEnumMemberName("uncut")] Uncut } +// FilterMode persists as its integer value in saved_filters."Mode"; that stored integer is +// independent of the camelCase string wire form. Members remain append-only so existing rows' +// modes stay stable. public enum FilterMode { Videos, diff --git a/src/Cove.Core/Events/Events.cs b/src/Cove.Core/Events/Events.cs index 2eeb363b..ae395675 100644 --- a/src/Cove.Core/Events/Events.cs +++ b/src/Cove.Core/Events/Events.cs @@ -1,29 +1,60 @@ +using System.Text.Json.Serialization; + namespace Cove.Core.Events; +// SignalR wire contract — pinned wire names are public API; do not rename. public enum EventType { // Entity lifecycle - VideoCreated, VideoUpdated, VideoDeleted, - PerformerCreated, PerformerUpdated, PerformerDeleted, - TagCreated, TagUpdated, TagDeleted, TagMerged, - StudioCreated, StudioUpdated, StudioDeleted, - GalleryCreated, GalleryUpdated, GalleryDeleted, - ImageCreated, ImageUpdated, ImageDeleted, - AudioCreated, AudioUpdated, AudioDeleted, - TextCreated, TextUpdated, TextDeleted, - GroupCreated, GroupUpdated, GroupDeleted, + [JsonStringEnumMemberName("videoCreated")] VideoCreated, + [JsonStringEnumMemberName("videoUpdated")] VideoUpdated, + [JsonStringEnumMemberName("videoDeleted")] VideoDeleted, + [JsonStringEnumMemberName("performerCreated")] PerformerCreated, + [JsonStringEnumMemberName("performerUpdated")] PerformerUpdated, + [JsonStringEnumMemberName("performerDeleted")] PerformerDeleted, + [JsonStringEnumMemberName("tagCreated")] TagCreated, + [JsonStringEnumMemberName("tagUpdated")] TagUpdated, + [JsonStringEnumMemberName("tagDeleted")] TagDeleted, + [JsonStringEnumMemberName("tagMerged")] TagMerged, + [JsonStringEnumMemberName("studioCreated")] StudioCreated, + [JsonStringEnumMemberName("studioUpdated")] StudioUpdated, + [JsonStringEnumMemberName("studioDeleted")] StudioDeleted, + [JsonStringEnumMemberName("galleryCreated")] GalleryCreated, + [JsonStringEnumMemberName("galleryUpdated")] GalleryUpdated, + [JsonStringEnumMemberName("galleryDeleted")] GalleryDeleted, + [JsonStringEnumMemberName("imageCreated")] ImageCreated, + [JsonStringEnumMemberName("imageUpdated")] ImageUpdated, + [JsonStringEnumMemberName("imageDeleted")] ImageDeleted, + [JsonStringEnumMemberName("audioCreated")] AudioCreated, + [JsonStringEnumMemberName("audioUpdated")] AudioUpdated, + [JsonStringEnumMemberName("audioDeleted")] AudioDeleted, + [JsonStringEnumMemberName("textCreated")] TextCreated, + [JsonStringEnumMemberName("textUpdated")] TextUpdated, + [JsonStringEnumMemberName("textDeleted")] TextDeleted, + [JsonStringEnumMemberName("groupCreated")] GroupCreated, + [JsonStringEnumMemberName("groupUpdated")] GroupUpdated, + [JsonStringEnumMemberName("groupDeleted")] GroupDeleted, // User set or cleared a rating on an entity. The published EntityEvent carries a // Dictionary { userId, aspect, value } as its Entity payload (value null = cleared). - RatingCreated, RatingUpdated, RatingDeleted, + [JsonStringEnumMemberName("ratingCreated")] RatingCreated, + [JsonStringEnumMemberName("ratingUpdated")] RatingUpdated, + [JsonStringEnumMemberName("ratingDeleted")] RatingDeleted, // Jobs - ScanStarted, ScanProgress, ScanCompleted, - GenerateStarted, GenerateProgress, GenerateCompleted, - CleanStarted, CleanProgress, CleanCompleted, + [JsonStringEnumMemberName("scanStarted")] ScanStarted, + [JsonStringEnumMemberName("scanProgress")] ScanProgress, + [JsonStringEnumMemberName("scanCompleted")] ScanCompleted, + [JsonStringEnumMemberName("generateStarted")] GenerateStarted, + [JsonStringEnumMemberName("generateProgress")] GenerateProgress, + [JsonStringEnumMemberName("generateCompleted")] GenerateCompleted, + [JsonStringEnumMemberName("cleanStarted")] CleanStarted, + [JsonStringEnumMemberName("cleanProgress")] CleanProgress, + [JsonStringEnumMemberName("cleanCompleted")] CleanCompleted, // System - ServerStarted, ServerStopping + [JsonStringEnumMemberName("serverStarted")] ServerStarted, + [JsonStringEnumMemberName("serverStopping")] ServerStopping } public record CoveEvent(EventType Type, object? Data = null); diff --git a/src/Cove.Core/Interfaces/IJobService.cs b/src/Cove.Core/Interfaces/IJobService.cs index 340dd305..658db3ba 100644 --- a/src/Cove.Core/Interfaces/IJobService.cs +++ b/src/Cove.Core/Interfaces/IJobService.cs @@ -1,24 +1,26 @@ using System.Collections.Concurrent; using System.Globalization; +using System.Text.Json.Serialization; using Cove.Core.DTOs; using Cove.Core.Events; namespace Cove.Core.Interfaces; +// SignalR job wire contract — pinned wire names are public API; do not rename. public enum JobStatus { - Pending, - Running, - Completed, - Failed, - Cancelled + [JsonStringEnumMemberName("pending")] Pending, + [JsonStringEnumMemberName("running")] Running, + [JsonStringEnumMemberName("completed")] Completed, + [JsonStringEnumMemberName("failed")] Failed, + [JsonStringEnumMemberName("cancelled")] Cancelled } public enum JobUnitOutcome { - Succeeded, - Failed, - Skipped, + [JsonStringEnumMemberName("succeeded")] Succeeded, + [JsonStringEnumMemberName("failed")] Failed, + [JsonStringEnumMemberName("skipped")] Skipped, } public record JobInfo( diff --git a/src/Cove.Data/Auth/UserRoleServices.cs b/src/Cove.Data/Auth/UserRoleServices.cs index 724fb1eb..0858becf 100644 --- a/src/Cove.Data/Auth/UserRoleServices.cs +++ b/src/Cove.Data/Auth/UserRoleServices.cs @@ -1,4 +1,5 @@ using Cove.Core.Auth; +using Cove.Core.Common; using Cove.Core.Entities.Auth; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -14,11 +15,9 @@ public sealed class UserService : IUserService private const string SetupPurpose = "setup"; private static readonly TimeSpan InviteTokenTtl = TimeSpan.FromDays(7); private static readonly TimeSpan SetupTokenTtl = TimeSpan.FromHours(1); - private static readonly JsonSerializerOptions UiPreferencesJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; + // UserUiPreferencesDto is camelCase with no enum fields, so canonical output is byte-compatible + // with the previously-persisted UI-prefs column. + private static readonly JsonSerializerOptions UiPreferencesJsonOptions = CoveJson.Default; private readonly CoveContext _db; private readonly IAuditService _audit; diff --git a/src/Cove.Tests/EnumWireFormatTests.cs b/src/Cove.Tests/EnumWireFormatTests.cs new file mode 100644 index 00000000..4b1d85cf --- /dev/null +++ b/src/Cove.Tests/EnumWireFormatTests.cs @@ -0,0 +1,166 @@ +using System.Text.Json; +using Cove.Core.Auth; +using Cove.Core.Common; +using Cove.Core.DTOs; +using Cove.Core.Entities; +using Cove.Core.Enums; +using Cove.Core.Events; +using Cove.Core.Interfaces; + +namespace Cove.Tests; + +/// +/// Locks the enum wire contract: every Cove.Core enum serializes through the canonical +/// as a camelCase named string, reads leniently from both the +/// string and the underlying integer, and — critically — an enum on an UNREGISTERED type +/// (reflection-fallback path) still emits a camelCase string, proving the global converter, +/// not just the source-gen fast path, governs the enum policy. +/// +public class EnumWireFormatTests +{ + // The full enum surface: all 27 Cove.Core enums. + public static readonly Type[] AllCoreEnums = + [ + typeof(GenderEnum), typeof(CircumcisedEnum), typeof(FilterMode), typeof(SortDirection), + typeof(CriterionModifier), typeof(EventType), typeof(BulkUpdateMode), typeof(PrincipalKind), + typeof(PermissionMode), typeof(RatingSystemType), typeof(RatingStarPrecision), + typeof(FaceAppearanceHostType), typeof(RatingHostType), typeof(InteractionHostType), + typeof(InteractionKind), typeof(AffinityHostType), typeof(SegmentHostType), + typeof(DetectionHostType), typeof(EmbeddingHostType), typeof(EmbeddingModality), + typeof(PlaybackSessionState), typeof(AiRunTargetType), typeof(AiRunStatus), + typeof(JobStatus), typeof(JobUnitOutcome), typeof(GroupKind), typeof(GroupItemKind), + ]; + + [Fact] + public void AllCoreEnums_CountIs27() + { + Assert.Equal(27, AllCoreEnums.Length); + Assert.Equal(27, AllCoreEnums.Distinct().Count()); + } + + public static IEnumerable EnumMembers() + { + foreach (var enumType in AllCoreEnums) + foreach (var value in Enum.GetValues(enumType)) + yield return [enumType, value!]; + } + + [Theory] + [MemberData(nameof(EnumMembers))] + public void EveryEnumMember_SerializesToCamelCaseString(Type enumType, object value) + { + var json = JsonSerializer.Serialize(value, enumType, CoveJson.Default); + + // Must be a quoted string, never a bare integer. + Assert.StartsWith("\"", json); + Assert.EndsWith("\"", json); + + var actual = json.Trim('"'); + var expected = ExpectedWireName(enumType, value); + Assert.Equal(expected, actual); + + // First character is lowercase — proves camelCase, not PascalCase or integer. + Assert.True(char.IsLower(actual[0]), $"{enumType.Name}.{value} wire form '{actual}' is not camelCase"); + } + + [Theory] + [MemberData(nameof(EnumMembers))] + public void EveryEnumMember_ReadsFromBothStringAndInteger(Type enumType, object value) + { + var wireName = ExpectedWireName(enumType, value); + var underlying = Convert.ToInt64(value); + + var fromString = JsonSerializer.Deserialize($"\"{wireName}\"", enumType, CoveJson.Default); + var fromInteger = JsonSerializer.Deserialize(underlying.ToString(), enumType, CoveJson.Default); + + Assert.Equal(value, fromString); + Assert.Equal(value, fromInteger); + } + + // Pinned literals: asserting the exact hardcoded wire string proves the + // [JsonStringEnumMemberName] pins are effective (decoupled from the C# identifier). + [Theory] + [InlineData(typeof(GenderEnum), (int)GenderEnum.NonBinary, "nonBinary")] + [InlineData(typeof(GenderEnum), (int)GenderEnum.TransgenderMale, "transgenderMale")] + [InlineData(typeof(CircumcisedEnum), (int)CircumcisedEnum.Uncut, "uncut")] + [InlineData(typeof(JobStatus), (int)JobStatus.Completed, "completed")] + [InlineData(typeof(JobUnitOutcome), (int)JobUnitOutcome.Succeeded, "succeeded")] + [InlineData(typeof(EventType), (int)EventType.VideoCreated, "videoCreated")] + [InlineData(typeof(EventType), (int)EventType.ServerStopping, "serverStopping")] + public void PinnedEnumMembers_KeepTheirWireName(Type enumType, int value, string expected) + { + var boxed = Enum.ToObject(enumType, value); + var json = JsonSerializer.Serialize(boxed, enumType, CoveJson.Default); + Assert.Equal($"\"{expected}\"", json); + } + + [Fact] + public void UnregisteredEnum_SerializesToCamelCaseString_ViaReflectionFallback() + { + // UnregisteredEnumHolder is NOT reachable from any [JsonSerializable] root in + // CoveJsonContext, so it resolves through the reflection fallback. The enum field must + // still be a camelCase STRING, proving the GLOBAL JsonStringEnumConverter — not the + // source-gen UseStringEnumConverter fast path — governs every extension-ALC and + // unregistered host DTO enum. Without the global converter this would be an integer. + var holder = new UnregisteredEnumHolder(GenderEnum.NonBinary); + var json = JsonSerializer.Serialize(holder, CoveJson.Default); + + Assert.Contains("\"value\":\"nonBinary\"", json); + Assert.DoesNotContain("\"value\":5", json); + } + + [Fact] + public void CriterionModifier_CamelCaseParity_ThroughCanonicalOptions() + { + // Byte-identical to the pre-canonical bespoke options output. + var expected = new Dictionary + { + [CriterionModifier.Equals] = "equals", + [CriterionModifier.NotEquals] = "notEquals", + [CriterionModifier.GreaterThan] = "greaterThan", + [CriterionModifier.LessThan] = "lessThan", + [CriterionModifier.Includes] = "includes", + [CriterionModifier.Excludes] = "excludes", + [CriterionModifier.IncludesAll] = "includesAll", + [CriterionModifier.ExcludesAll] = "excludesAll", + [CriterionModifier.IsNull] = "isNull", + [CriterionModifier.NotNull] = "notNull", + [CriterionModifier.Between] = "between", + [CriterionModifier.NotBetween] = "notBetween", + [CriterionModifier.MatchesRegex] = "matchesRegex", + [CriterionModifier.NotMatchesRegex] = "notMatchesRegex", + }; + + foreach (var (modifier, wire) in expected) + Assert.Equal($"\"{wire}\"", JsonSerializer.Serialize(modifier, CoveJson.Default)); + } + + [Theory] + [InlineData("\"greater_than\"", CriterionModifier.GreaterThan)] + [InlineData("\"GREATER_THAN\"", CriterionModifier.GreaterThan)] + [InlineData("\"includesAll\"", CriterionModifier.IncludesAll)] + [InlineData("6", CriterionModifier.IncludesAll)] + public void CriterionModifier_LenientRead_ThroughCanonicalOptions(string json, CriterionModifier expected) + { + var actual = JsonSerializer.Deserialize(json, CoveJson.Default); + Assert.Equal(expected, actual); + } + + // Resolves the expected wire name: honors any [JsonStringEnumMemberName] pin, otherwise the + // default camelCase policy — the same rule the global converter applies. + private static string ExpectedWireName(Type enumType, object value) + { + var member = enumType.GetMember(value.ToString()!); + var pin = member.Length > 0 + ? member[0] + .GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonStringEnumMemberNameAttribute), false) + .Cast() + .FirstOrDefault() + : null; + + return pin?.Name ?? JsonNamingPolicy.CamelCase.ConvertName(value.ToString()!); + } + + // NOT registered in CoveJsonContext — deliberately exercises the reflection fallback. + private sealed record UnregisteredEnumHolder(GenderEnum Value); +} diff --git a/src/Cove.Tests/Integration/IntegrationHttpJson.cs b/src/Cove.Tests/Integration/IntegrationHttpJson.cs index c23f8852..b4996308 100644 --- a/src/Cove.Tests/Integration/IntegrationHttpJson.cs +++ b/src/Cove.Tests/Integration/IntegrationHttpJson.cs @@ -1,20 +1,15 @@ using System.Net.Http.Json; using System.Text.Json; -using System.Text.Json.Serialization; +using Cove.Core.Common; namespace Cove.Tests.Integration; internal static class IntegrationHttpJson { - public static readonly JsonSerializerOptions Options = CreateOptions(); + // Read integration responses through the real canonical options so tests assert against the + // actual wire contract and would catch any drift in CoveJson.Default. + public static readonly JsonSerializerOptions Options = CoveJson.Default; public static Task ReadApiJsonAsync(this HttpContent content, CancellationToken cancellationToken = default) => content.ReadFromJsonAsync(Options, cancellationToken); - - private static JsonSerializerOptions CreateOptions() - { - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); - options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - return options; - } -} \ No newline at end of file +} diff --git a/src/Cove.Tests/Integration/SerializerWireParityTests.cs b/src/Cove.Tests/Integration/SerializerWireParityTests.cs new file mode 100644 index 00000000..45287347 --- /dev/null +++ b/src/Cove.Tests/Integration/SerializerWireParityTests.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using Cove.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Cove.Tests.Integration; + +public sealed class SerializerWireParityTests +{ + // An enum-carrying payload deliberately NOT reachable from any [JsonSerializable] root in + // CoveJsonContext, mirroring a per-extension AssemblyLoadContext type. Serializing it forces + // the reflection-fallback resolver + the global string-enum converter — the exact path that + // minimal-API return values and extension MapEndpoints responses take. A registered DTO would + // let this pass on the source-gen fast path while the extension path stayed broken. + private sealed record UnregisteredWireProbe(JobStatus Status); + + [Fact] + public async Task MvcSignalRAndHttpJson_SerializeEnum_ToIdenticalCamelCaseString() + { + using var factory = new CoveWebApplicationFactory(); + await factory.ResetDatabaseAsync(); + + var services = factory.Services; + var mvc = services + .GetRequiredService>() + .Value.JsonSerializerOptions; + var httpJson = services + .GetRequiredService>() + .Value.SerializerOptions; + var signalr = services + .GetRequiredService>() + .Value.PayloadSerializerOptions; + + var mvcJson = JsonSerializer.Serialize(JobStatus.Running, mvc); + var httpJsonJson = JsonSerializer.Serialize(JobStatus.Running, httpJson); + var signalrJson = JsonSerializer.Serialize(JobStatus.Running, signalr); + + Assert.Equal("\"running\"", mvcJson); + Assert.Equal("\"running\"", httpJsonJson); + Assert.Equal("\"running\"", signalrJson); + Assert.Equal(mvcJson, httpJsonJson); + Assert.Equal(mvcJson, signalrJson); + } + + [Fact] + public async Task HttpJsonOptions_SerializeUnregisteredDtoEnum_AsCamelCaseString() + { + using var factory = new CoveWebApplicationFactory(); + await factory.ResetDatabaseAsync(); + + // The exact options object every minimal-API return value, Results.Json call, and extension + // MapEndpoints / HttpContext.Response.WriteAsJsonAsync response uses at runtime — wired only + // by ConfigureHttpJsonOptions. Without it, this path emits enums as integers. + var httpJson = factory.Services + .GetRequiredService>() + .Value.SerializerOptions; + + var json = JsonSerializer.Serialize(new UnregisteredWireProbe(JobStatus.Pending), httpJson); + + Assert.Contains("\"status\":\"pending\"", json); + Assert.DoesNotContain("\"status\":0", json); + } + + [Fact] + public async Task SystemConfigEndpoint_EnumField_IsCamelCaseString() + { + using var factory = new CoveWebApplicationFactory(); + await factory.ResetDatabaseAsync(); + + using var client = factory.CreateAuthenticatedClient(); + var response = await client.GetAsync("/api/system/config"); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync(); + + // RatingSystemOptions.Type is a RatingSystemType enum; controllers already emitted it as a + // camelCase string, so this asserts the existing wire output is preserved, not changed. + Assert.Contains("\"type\":\"stars\"", body); + Assert.DoesNotContain("\"type\":0", body); + } +} diff --git a/src/Cove.Tests/SourceGenEnumParityTests.cs b/src/Cove.Tests/SourceGenEnumParityTests.cs new file mode 100644 index 00000000..a5d41976 --- /dev/null +++ b/src/Cove.Tests/SourceGenEnumParityTests.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Cove.Core.Common; + +namespace Cove.Tests; + +// An unpinned, multi-word enum: camelCase ("multiWordValue") and PascalCase ("MultiWordValue") +// differ, so the wire string reveals which naming rule the source-gen path actually applied. +// No [JsonStringEnumMemberName] pin — this is exactly the unpinned enum member WR-02 warns about. +internal enum ProbeEnum +{ + MultiWordValue, + AnotherValue, +} + +internal sealed record EnumBearingDto(ProbeEnum Value); + +// A source-gen context whose [JsonSourceGenerationOptions] mirror the production CoveJsonContext +// exactly (camelCase properties, UseStringEnumConverter). Registering EnumBearingDto forces the +// enum through the source-generated metadata rather than the reflection fallback. +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] +[JsonSerializable(typeof(EnumBearingDto))] +internal partial class ProbeJsonContext : JsonSerializerContext +{ +} + +/// +/// Guards against a source-gen vs reflection split-brain for enums. Registered host DTOs serialize +/// through source-generated metadata, where UseStringEnumConverter = true emits a per-type +/// string-enum converter; the reflection-fallback path camelCases enums via the global converter on +/// the options. This test builds options identically to — the +/// source-gen context combined with a reflection resolver plus the global camelCase string-enum +/// converter — and asserts an unpinned, multi-word enum on a registered DTO still emits the same +/// camelCase string on the source-gen path, so the two boundaries cannot diverge. +/// +public class SourceGenEnumParityTests +{ + // Options built the same way CoveJson.Default is, but over the probe source-gen context. + private static JsonSerializerOptions BuildSourceGenOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine( + ProbeJsonContext.Default, + new DefaultJsonTypeInfoResolver()), + }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + options.MakeReadOnly(); + return options; + } + + [Fact] + public void UnpinnedEnumOnRegisteredDto_SerializesToCamelCaseString_ViaSourceGenPath() + { + var json = JsonSerializer.Serialize(new EnumBearingDto(ProbeEnum.MultiWordValue), BuildSourceGenOptions()); + + // camelCase named string — never PascalCase, never the underlying integer. + Assert.Contains("\"value\":\"multiWordValue\"", json); + Assert.DoesNotContain("MultiWordValue", json); + Assert.DoesNotContain("\"value\":0", json); + } +} diff --git a/src/Cove.Tests/SpecialTypeRoundTripTests.cs b/src/Cove.Tests/SpecialTypeRoundTripTests.cs new file mode 100644 index 00000000..a0639449 --- /dev/null +++ b/src/Cove.Tests/SpecialTypeRoundTripTests.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Cove.Core.Common; + +namespace Cove.Tests; + +/// +/// Locks the special-type wire policy through the canonical : +/// DateTime/DateTimeOffset are ISO-8601, Guid is the 36-char lowercase "D" form, and byte[] is +/// base64 — all System.Text.Json defaults, no custom converter. Also asserts that no boundary +/// DTO field is decimal, so the JsonNumberHandling.WriteAsString opt-in list stays +/// empty (no decimal properties exist in the Cove.Core.DTOs surface). +/// +public class SpecialTypeRoundTripTests +{ + private sealed record SpecialShape( + DateTime When, + DateTimeOffset WhenOffset, + Guid Id, + byte[] Blob); + + [Fact] + public void DateTime_IsIso8601_ThroughCanonicalOptions() + { + var when = new DateTime(2026, 7, 19, 13, 45, 30, DateTimeKind.Utc); + var json = JsonSerializer.Serialize(when, CoveJson.Default).Trim('"'); + + Assert.StartsWith("2026-07-19T13:45:30", json); + // ISO-8601 round-trips back to the same instant. + var back = JsonSerializer.Deserialize($"\"{json}\"", CoveJson.Default); + Assert.Equal(when, back.ToUniversalTime()); + } + + [Fact] + public void Guid_Is36CharLowercaseD_ThroughCanonicalOptions() + { + var id = Guid.NewGuid(); + var json = JsonSerializer.Serialize(id, CoveJson.Default).Trim('"'); + + Assert.Equal(36, json.Length); + Assert.Matches(new Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"), json); + Assert.Equal(id, JsonSerializer.Deserialize($"\"{json}\"", CoveJson.Default)); + } + + [Fact] + public void ByteArray_IsBase64_ThroughCanonicalOptions() + { + var blob = new byte[] { 1, 2, 3, 4, 250 }; + var json = JsonSerializer.Serialize(blob, CoveJson.Default).Trim('"'); + + Assert.Equal(Convert.ToBase64String(blob), json); + Assert.Equal(blob, JsonSerializer.Deserialize($"\"{json}\"", CoveJson.Default)); + } + + [Fact] + public void SpecialShape_RoundTrips_ThroughCanonicalOptions() + { + var original = new SpecialShape( + new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.FromHours(2)), + Guid.NewGuid(), + [10, 20, 30]); + + var json = JsonSerializer.Serialize(original, CoveJson.Default); + var back = JsonSerializer.Deserialize(json, CoveJson.Default); + + Assert.NotNull(back); + Assert.Equal(original.When, back!.When); + Assert.Equal(original.WhenOffset, back.WhenOffset); + Assert.Equal(original.Id, back.Id); + Assert.Equal(original.Blob, back.Blob); + + // camelCase property names on the wire. + Assert.Contains("\"when\":", json); + Assert.Contains("\"id\":", json); + Assert.Contains("\"blob\":", json); + } + + [Fact] + public void NoDecimalWireFields_SoWriteAsStringOptInListIsEmpty() + { + // No boundary DTO carries a decimal, so no field opts into JsonNumberHandling.WriteAsString. + // Precision-sensitive numerics that cross the wire are double / float[], which are JSON + // numbers by default. Reflect over the DTO surface and fail if a decimal is ever introduced. + var decimalProps = typeof(CoveJson).Assembly.GetTypes() + .Where(t => t.Namespace == "Cove.Core.DTOs") + .SelectMany(t => t.GetProperties()) + .Where(p => p.PropertyType == typeof(decimal) || p.PropertyType == typeof(decimal?)) + .Select(p => $"{p.DeclaringType!.Name}.{p.Name}") + .ToList(); + + Assert.Empty(decimalProps); + } +} diff --git a/src/Cove.Tests/UserUiPreferencesRoundTripTests.cs b/src/Cove.Tests/UserUiPreferencesRoundTripTests.cs new file mode 100644 index 00000000..07a4f6b6 --- /dev/null +++ b/src/Cove.Tests/UserUiPreferencesRoundTripTests.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using Cove.Core.Auth; +using Cove.Core.Common; + +namespace Cove.Tests; + +public class UserUiPreferencesRoundTripTests +{ + private static UserUiPreferencesDto BuildPreferences() => new( + Theme: new UserThemePreferencesDto( + ActiveThemeId: "midnight", + ActiveComponentStyles: ["rounded", "compact"], + ActiveLayoutStyle: "grid", + CustomThemeColors: new Dictionary { ["accent"] = "#ff8800" }, + StyleOptions: new Dictionary> + { + ["card"] = new() { ["radius"] = "8px" }, + }), + RatingSystemOptions: new UserRatingSystemOptionsDto(Type: "stars", StarPrecision: "half"), + Tracking: new UserTrackingPreferencesDto( + Enabled: true, + MinViewSeconds: 5, + ViewCompletionRatio: 0.75, + MinImageDetailViewSeconds: 3, + MinDerivedLikeSessionSeconds: 30, + SessionIdleTimeoutSec: 120, + DwellPositiveSec: 10), + Videos: new UserVideosPreferencesDto(IncludeCompilationGroups: false), + KeybindingOverrides: new Dictionary { ["playPause"] = "space" }, + Playback: new UserPlaybackPreferencesDto(SkipSeconds: 15), + HomePageContent: "{\"rows\":[]}", + DefaultFilters: new Dictionary { ["videos"] = "{\"sort\":\"name\"}" }); + + [Fact] + public void RoundTrip_PopulatedPreferences_PreservesValue() + { + var original = BuildPreferences(); + + var json = JsonSerializer.Serialize(original, CoveJson.Default); + var roundTrip = JsonSerializer.Deserialize(json, CoveJson.Default); + + Assert.NotNull(roundTrip); + // Nested records without dictionaries compare structurally. + Assert.Equal(original.RatingSystemOptions, roundTrip!.RatingSystemOptions); + Assert.Equal(original.Tracking, roundTrip.Tracking); + Assert.Equal(original.Videos, roundTrip.Videos); + Assert.Equal(original.Playback, roundTrip.Playback); + Assert.Equal(original.HomePageContent, roundTrip.HomePageContent); + Assert.Equal(original.KeybindingOverrides, roundTrip.KeybindingOverrides); + Assert.Equal(original.DefaultFilters, roundTrip.DefaultFilters); + // Full-graph fidelity: re-serializing the round-tripped value reproduces the original JSON. + Assert.Equal(json, JsonSerializer.Serialize(roundTrip, CoveJson.Default)); + } + + [Fact] + public void Serialize_MatchesPreConsolidationShape_NoDrift() + { + var preferences = BuildPreferences(); + + // The exact options UserService used before consolidating onto CoveJson.Default. + var legacyOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + var canonical = JsonSerializer.Serialize(preferences, CoveJson.Default); + var legacy = JsonSerializer.Serialize(preferences, legacyOptions); + + // Switching UiPreferencesJsonOptions -> CoveJson.Default must not drift the persisted blob. + Assert.Equal(legacy, canonical); + } + + [Fact] + public void Serialize_MinimalPreferences_MatchesPinnedCamelCaseFixture() + { + var preferences = new UserUiPreferencesDto( + Theme: null, + RatingSystemOptions: null, + Tracking: null, + Videos: null, + KeybindingOverrides: new Dictionary { ["playPause"] = "space" }, + Playback: null, + HomePageContent: "home-json", + DefaultFilters: null); + + var json = JsonSerializer.Serialize(preferences, CoveJson.Default); + + const string expected = + "{\"theme\":null,\"ratingSystemOptions\":null,\"tracking\":null,\"videos\":null," + + "\"keybindingOverrides\":{\"playPause\":\"space\"},\"playback\":null," + + "\"homePageContent\":\"home-json\",\"defaultFilters\":null}"; + + Assert.Equal(expected, json); + } +}