diff --git a/README.md b/README.md index 3d0b1b3..41deb15 100644 --- a/README.md +++ b/README.md @@ -529,16 +529,20 @@ public async Task FastConversion() } ``` -### Watermark & Rotation -*Add text watermarks and rotate PDF pages — available on all request types:* +### Wait For Selector & Emulated Media Features +*Wait for a DOM element and emulate CSS media features like dark mode:* ```csharp -public async Task CreateWatermarkedPdf() +public async Task CreateWithChromiumFeatures() { var builder = new HtmlRequestBuilder() - .AddDocument(doc => doc.SetBody("

Report

")) - .SetWatermarkOptions(w => w.SetTextWatermark("DRAFT", "1-3")) - .SetRotationOptions(r => r.SetAngle(90).SetPages("2")) + .AddDocument(doc => doc.SetBody("
Ready
")) + .SetConversionBehaviors(b => b + .SetWaitForSelector("#app") + .AddEmulatedMediaFeature("prefers-color-scheme", "dark") + .SetFailOnHttpStatusCodes(499, 599) + .FailOnResourceLoadingFailed() + .AddIgnoreResourceHttpStatusDomains("cdn.example.com")) .WithPageProperties(pp => pp.UseChromeDefaults()); var request = builder.Build(); @@ -546,20 +550,6 @@ public async Task CreateWatermarkedPdf() } ``` -### Split PDFs -*Split generated PDFs into chunks or extract specific pages:* - -```csharp -public async Task SplitPdf() -{ - var builder = new HtmlRequestBuilder() - .AddDocument(doc => doc.SetBody("Multi-page content")) - .SetSplitOptions(s => s.SplitByPages("1-3,5", unify: true)); - - var request = builder.Build(); - return await _sharpClient.HtmlToPdfAsync(request); -} -``` ### Custom Page Properties *Fine-tune page dimensions and properties:* diff --git a/examples/ChromiumFeatures/ChromiumFeatures.csproj b/examples/ChromiumFeatures/ChromiumFeatures.csproj new file mode 100644 index 0000000..35e3d84 --- /dev/null +++ b/examples/ChromiumFeatures/ChromiumFeatures.csproj @@ -0,0 +1,2 @@ + + diff --git a/examples/ChromiumFeatures/Program.cs b/examples/ChromiumFeatures/Program.cs new file mode 100644 index 0000000..702ba81 --- /dev/null +++ b/examples/ChromiumFeatures/Program.cs @@ -0,0 +1,81 @@ +using Gotenberg.Sharp.API.Client; +using Gotenberg.Sharp.API.Client.Domain.Builders; +using Gotenberg.Sharp.API.Client.Domain.Settings; +using Gotenberg.Sharp.API.Client.Infrastructure.Pipeline; + +using Microsoft.Extensions.Configuration; + +var config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json") + .Build(); + +var options = new GotenbergSharpClientOptions(); +config.GetSection(nameof(GotenbergSharpClient)).Bind(options); + +var destinationDirectory = args.Length > 0 ? args[0] : Path.Combine(Directory.GetCurrentDirectory(), "output"); +Directory.CreateDirectory(destinationDirectory); + +var path = await CreateWithChromiumFeatures(destinationDirectory, options); +Console.WriteLine($"PDF created: {path}"); + +static async Task CreateWithChromiumFeatures(string destinationDirectory, GotenbergSharpClientOptions options) +{ + var handler = new HttpClientHandler(); + HttpMessageHandler effectiveHandler = handler; + if (!string.IsNullOrWhiteSpace(options.BasicAuthUsername) && !string.IsNullOrWhiteSpace(options.BasicAuthPassword)) + effectiveHandler = new BasicAuthHandler(options.BasicAuthUsername, options.BasicAuthPassword) { InnerHandler = handler }; + + using var httpClient = new HttpClient(effectiveHandler, disposeHandler: true) + { + BaseAddress = options.ServiceUrl, + Timeout = options.TimeOut + }; + + var sharpClient = new GotenbergSharpClient(httpClient); + + // Demonstrates waitForSelector, emulated media features, and error handling options + var builder = new HtmlRequestBuilder() + .AddDocument(doc => doc.SetBody(@" + + + + + +
+

Chromium Feature Demo

+

This PDF was generated with dark mode emulation, waitForSelector, + and strict error handling.

+
+ + ")) + .SetConversionBehaviors(b => b + // Wait for the #content element before converting + .SetWaitForSelector("#content") + // Emulate dark mode + .AddEmulatedMediaFeature("prefers-color-scheme", "dark") + // Fail if the main page returns 4xx or 5xx + .SetFailOnHttpStatusCodes(499, 599) + // Fail if any resource fails to load + .FailOnResourceLoadingFailed() + // Fail on any console exceptions + .FailOnConsoleExceptions() + // Ignore CDN domains for status code checks + .AddIgnoreResourceHttpStatusDomains("cdn.example.com") + ) + .WithPageProperties(pp => pp.UseChromeDefaults()); + + var request = builder.Build(); + var response = await sharpClient.HtmlToPdfAsync(request); + + var resultPath = Path.Combine(destinationDirectory, $"ChromiumFeatures-{DateTime.Now:yyyyMMddHHmmss}.pdf"); + + await using var destinationStream = File.Create(resultPath); + await response.CopyToAsync(destinationStream, CancellationToken.None); + + return resultPath; +} diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/Builders/Faceted/HtmlConversionBehaviorBuilder.cs b/src/Gotenberg.Sharp.Api.Client/Domain/Builders/Faceted/HtmlConversionBehaviorBuilder.cs index 78e9d90..35b9af1 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/Builders/Faceted/HtmlConversionBehaviorBuilder.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/Builders/Faceted/HtmlConversionBehaviorBuilder.cs @@ -13,6 +13,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Gotenberg.Sharp.API.Client.Domain.ValueObjects; + using Newtonsoft.Json.Linq; namespace Gotenberg.Sharp.API.Client.Domain.Builders.Faceted; @@ -174,6 +176,171 @@ public HtmlConversionBehaviorBuilder SkipNetworkIdleEvent() return this; } + /// + /// Sets a CSS selector to wait for before conversion. + /// Chromium will delay conversion until the specified element appears in the DOM. + /// + /// A validated CSS selector. + /// The builder instance for method chaining. + public HtmlConversionBehaviorBuilder SetWaitForSelector(CssSelector selector) + { + _htmlConversionBehaviors.WaitForSelector = selector ?? throw new ArgumentNullException(nameof(selector)); + + return this; + } + + /// + /// Sets a CSS selector to wait for before conversion. + /// Chromium will delay conversion until the specified element appears in the DOM. + /// + /// A CSS selector string (e.g., "#content", ".loaded"). + /// The builder instance for method chaining. + public HtmlConversionBehaviorBuilder SetWaitForSelector(string selector) + { + return SetWaitForSelector(CssSelector.Create(selector)); + } + + /// + /// Adds a CSS media feature override for Chromium rendering. + /// + /// A validated emulated media feature. + /// The builder instance for method chaining. + public HtmlConversionBehaviorBuilder AddEmulatedMediaFeature(EmulatedMediaFeature feature) + { + if (feature == null) throw new ArgumentNullException(nameof(feature)); + + _htmlConversionBehaviors.EmulatedMediaFeatures ??= new List(); + _htmlConversionBehaviors.EmulatedMediaFeatures.Add(feature); + + return this; + } + + /// + /// Adds a CSS media feature override by name and value. + /// + /// CSS media feature name (e.g., "prefers-color-scheme"). + /// CSS media feature value (e.g., "dark"). + /// The builder instance for method chaining. + public HtmlConversionBehaviorBuilder AddEmulatedMediaFeature(string name, string value) + { + return AddEmulatedMediaFeature(EmulatedMediaFeature.Create(name, value)); + } + + /// + /// Sets HTTP status codes that trigger a 409 Conflict when the main page returns them. + /// + /// Validated HTTP status codes. + /// The builder instance for method chaining. + public HtmlConversionBehaviorBuilder SetFailOnHttpStatusCodes(IEnumerable statusCodes) + { + if (statusCodes == null) throw new ArgumentNullException(nameof(statusCodes)); + + var codes = statusCodes.ToList(); + + if (codes.Any(c => c == null)) + throw new ArgumentException("Status codes collection must not contain null elements.", nameof(statusCodes)); + + _htmlConversionBehaviors.FailOnHttpStatusCodes = codes; + + return this; + } + + /// + /// Sets HTTP status codes that trigger a 409 Conflict when the main page returns them. + /// + /// Raw HTTP status code integers (must be 100-599). + /// The builder instance for method chaining. + public HtmlConversionBehaviorBuilder SetFailOnHttpStatusCodes(params int[] statusCodes) + { + if (statusCodes == null) throw new ArgumentNullException(nameof(statusCodes)); + + return SetFailOnHttpStatusCodes(statusCodes.Select(GotenbergStatusCode.Create)); + } + + /// + /// Sets HTTP status codes that trigger a failure when page resources return them. + /// + /// Validated HTTP status codes. + /// The builder instance for method chaining. + public HtmlConversionBehaviorBuilder SetFailOnResourceHttpStatusCodes(IEnumerable statusCodes) + { + if (statusCodes == null) throw new ArgumentNullException(nameof(statusCodes)); + + var codes = statusCodes.ToList(); + + if (codes.Any(c => c == null)) + throw new ArgumentException("Status codes collection must not contain null elements.", nameof(statusCodes)); + + _htmlConversionBehaviors.FailOnResourceHttpStatusCodes = codes; + + return this; + } + + /// + /// Sets HTTP status codes that trigger a failure when page resources return them. + /// + /// Raw HTTP status code integers (must be 100-599). + /// The builder instance for method chaining. + public HtmlConversionBehaviorBuilder SetFailOnResourceHttpStatusCodes(params int[] statusCodes) + { + if (statusCodes == null) throw new ArgumentNullException(nameof(statusCodes)); + + return SetFailOnResourceHttpStatusCodes(statusCodes.Select(GotenbergStatusCode.Create)); + } + + /// + /// Adds a domain to exclude from HTTP status code checks on resources. + /// + /// A validated domain name. + /// The builder instance for method chaining. + public HtmlConversionBehaviorBuilder AddIgnoreResourceHttpStatusDomain(DomainName domain) + { + if (domain == null) throw new ArgumentNullException(nameof(domain)); + + _htmlConversionBehaviors.IgnoreResourceHttpStatusDomains ??= new List(); + _htmlConversionBehaviors.IgnoreResourceHttpStatusDomains.Add(domain); + + return this; + } + + /// + /// Adds a domain to exclude from HTTP status code checks on resources. + /// + /// A domain string (e.g., "cdn.example.com"). + /// The builder instance for method chaining. + public HtmlConversionBehaviorBuilder AddIgnoreResourceHttpStatusDomain(string domain) + { + return AddIgnoreResourceHttpStatusDomain(DomainName.Create(domain)); + } + + /// + /// Adds multiple domains to exclude from HTTP status code checks on resources. + /// + /// Domain strings to exclude. + /// The builder instance for method chaining. + public HtmlConversionBehaviorBuilder AddIgnoreResourceHttpStatusDomains(params string[] domains) + { + if (domains == null) throw new ArgumentNullException(nameof(domains)); + + var validated = domains.Select(DomainName.Create).ToList(); + + _htmlConversionBehaviors.IgnoreResourceHttpStatusDomains ??= new List(); + _htmlConversionBehaviors.IgnoreResourceHttpStatusDomains.AddRange(validated); + + return this; + } + + /// + /// Tells Gotenberg to return a 409 Conflict if any resource fails to load due to network errors. + /// + /// The builder instance for method chaining. + public HtmlConversionBehaviorBuilder FailOnResourceLoadingFailed() + { + _htmlConversionBehaviors.FailOnResourceLoadingFailed = true; + + return this; + } + /// /// Sets the format of the resulting PDF document. /// diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/Requests/Facets/FacetBase.cs b/src/Gotenberg.Sharp.Api.Client/Domain/Requests/Facets/FacetBase.cs index 4ebbab4..a575b6e 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/Requests/Facets/FacetBase.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/Requests/Facets/FacetBase.cs @@ -81,6 +81,11 @@ public virtual IEnumerable ToHttpContent() ConversionPdfFormats format => format.ToFormDataValue(), PdfPassword password => password.Value, List cookies => JsonConvert.SerializeObject(cookies), + List features => JsonConvert.SerializeObject( + features.ToDictionary(f => f.Name, f => f.Value)), + List codes => JsonConvert.SerializeObject(codes.Select(c => c.Value)), + List domains => JsonConvert.SerializeObject(domains.Select(d => d.Value)), + CssSelector selector => selector.Value, OverlaySource overlaySource => overlaySource.ToFormValue(), SplitMode splitMode => splitMode.ToFormValue(), float f => f.ToString(cultureInfo), diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/Requests/Facets/HtmlConversionBehaviors.cs b/src/Gotenberg.Sharp.Api.Client/Domain/Requests/Facets/HtmlConversionBehaviors.cs index 449f0b3..9558586 100644 --- a/src/Gotenberg.Sharp.Api.Client/Domain/Requests/Facets/HtmlConversionBehaviors.cs +++ b/src/Gotenberg.Sharp.Api.Client/Domain/Requests/Facets/HtmlConversionBehaviors.cs @@ -13,6 +13,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Gotenberg.Sharp.API.Client.Domain.ValueObjects; + using Newtonsoft.Json.Linq; namespace Gotenberg.Sharp.API.Client.Domain.Requests.Facets; @@ -85,4 +87,44 @@ public class HtmlConversionBehaviors : FacetBase /// [MultiFormHeader(Constants.Gotenberg.Chromium.Shared.HtmlConvert.SkipNetworkIdleEvent)] public bool? SkipNetworkIdleEvent { get; set; } + + /// + /// CSS selector to wait for before conversion. Delays until the element appears in the DOM. + /// + [MultiFormHeader(Constants.Gotenberg.Chromium.Shared.HtmlConvert.WaitForSelector)] + public CssSelector? WaitForSelector { get; set; } + + /// + /// Overrides CSS media features (e.g., prefers-color-scheme, prefers-reduced-motion). + /// Sent as a JSON array of {name, value} objects. + /// + [MultiFormHeader(Constants.Gotenberg.Chromium.Shared.HtmlConvert.EmulatedMediaFeatures)] + public List? EmulatedMediaFeatures { get; set; } + + /// + /// HTTP status codes that trigger a 409 Conflict response from Gotenberg + /// when the main page returns a matching code. Default: [499, 599]. + /// + [MultiFormHeader(Constants.Gotenberg.Chromium.Shared.HtmlConvert.FailOnHttpStatusCodes)] + public List? FailOnHttpStatusCodes { get; set; } + + /// + /// HTTP status codes that trigger a failure when page resources (CSS, images, fonts) + /// return a matching code. + /// + [MultiFormHeader(Constants.Gotenberg.Chromium.Shared.HtmlConvert.FailOnResourceHttpStatusCodes)] + public List? FailOnResourceHttpStatusCodes { get; set; } + + /// + /// Domains to exclude from HTTP status code checks on resources. + /// Useful for ignoring third-party CDNs or analytics domains. + /// + [MultiFormHeader(Constants.Gotenberg.Chromium.Shared.HtmlConvert.IgnoreResourceHttpStatusDomains)] + public List? IgnoreResourceHttpStatusDomains { get; set; } + + /// + /// Tells Gotenberg to return a 409 Conflict if any resource fails to load due to network errors. + /// + [MultiFormHeader(Constants.Gotenberg.Chromium.Shared.HtmlConvert.FailOnResourceLoadingFailed)] + public bool? FailOnResourceLoadingFailed { get; set; } } diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/ValueObjects/CssSelector.cs b/src/Gotenberg.Sharp.Api.Client/Domain/ValueObjects/CssSelector.cs new file mode 100644 index 0000000..a36b400 --- /dev/null +++ b/src/Gotenberg.Sharp.Api.Client/Domain/ValueObjects/CssSelector.cs @@ -0,0 +1,59 @@ +// Copyright 2019-2026 Chris Mohan, Jaben Cargman +// and GotenbergSharpApiClient Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Gotenberg.Sharp.API.Client.Domain.ValueObjects; + +/// +/// Represents a validated CSS selector string used by Chromium's waitForSelector feature. +/// Delays conversion until the specified element appears in the DOM. +/// +public sealed class CssSelector : IEquatable +{ + public string Value { get; } + + private CssSelector(string value) + { + Value = value; + } + + /// + /// Creates a new CssSelector from the given string. + /// + /// A non-empty CSS selector string (e.g., "#content", ".loaded", "[data-ready]"). + /// Thrown when the selector is null or whitespace. + public static CssSelector Create(string selector) + { + if (string.IsNullOrWhiteSpace(selector)) + throw new ArgumentException("CSS selector must not be null or empty.", nameof(selector)); + + return new CssSelector(selector); + } + + public override string ToString() => Value; + + public bool Equals(CssSelector? other) => other is not null && Value == other.Value; + + public override bool Equals(object? obj) => Equals(obj as CssSelector); + + public override int GetHashCode() => Value.GetHashCode(); + + /// Thrown when is null. + public static implicit operator string(CssSelector selector) => + selector?.Value ?? throw new ArgumentNullException(nameof(selector)); + + public static bool operator ==(CssSelector? left, CssSelector? right) => Equals(left, right); + + public static bool operator !=(CssSelector? left, CssSelector? right) => !Equals(left, right); +} diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/ValueObjects/DomainName.cs b/src/Gotenberg.Sharp.Api.Client/Domain/ValueObjects/DomainName.cs new file mode 100644 index 0000000..60e5626 --- /dev/null +++ b/src/Gotenberg.Sharp.Api.Client/Domain/ValueObjects/DomainName.cs @@ -0,0 +1,60 @@ +// Copyright 2019-2026 Chris Mohan, Jaben Cargman +// and GotenbergSharpApiClient Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Gotenberg.Sharp.API.Client.Domain.ValueObjects; + +/// +/// Represents a validated domain name used for Gotenberg's ignoreResourceHttpStatusDomains field. +/// Domains matching this list are excluded from HTTP status code failure checks. +/// +public sealed class DomainName : IEquatable +{ + public string Value { get; } + + private DomainName(string value) + { + Value = value; + } + + /// + /// Creates a validated domain name. + /// + /// A non-empty domain string (e.g., "cdn.example.com", "fonts.googleapis.com"). + /// Thrown when domain is null or whitespace. + public static DomainName Create(string domain) + { + if (string.IsNullOrWhiteSpace(domain)) + throw new ArgumentException("Domain name must not be null or empty.", nameof(domain)); + + return new DomainName(domain.Trim()); + } + + public override string ToString() => Value; + + public bool Equals(DomainName? other) => other is not null + && string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + public override bool Equals(object? obj) => Equals(obj as DomainName); + + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// Thrown when is null. + public static implicit operator string(DomainName domain) => + domain?.Value ?? throw new ArgumentNullException(nameof(domain)); + + public static bool operator ==(DomainName? left, DomainName? right) => Equals(left, right); + + public static bool operator !=(DomainName? left, DomainName? right) => !Equals(left, right); +} diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/ValueObjects/EmulatedMediaFeature.cs b/src/Gotenberg.Sharp.Api.Client/Domain/ValueObjects/EmulatedMediaFeature.cs new file mode 100644 index 0000000..b639d1f --- /dev/null +++ b/src/Gotenberg.Sharp.Api.Client/Domain/ValueObjects/EmulatedMediaFeature.cs @@ -0,0 +1,94 @@ +// Copyright 2019-2026 Chris Mohan, Jaben Cargman +// and GotenbergSharpApiClient Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Newtonsoft.Json; + +namespace Gotenberg.Sharp.API.Client.Domain.ValueObjects; + +/// +/// Represents a single CSS media feature override for Chromium's emulatedMediaFeatures. +/// Each feature has a name (e.g., "prefers-color-scheme") and a value (e.g., "dark"). +/// Gotenberg expects a JSON array of these objects: [{"name":"...","value":"..."}]. +/// +public sealed record EmulatedMediaFeature +{ + /// + /// The CSS media feature name (e.g., "prefers-color-scheme", "prefers-reduced-motion"). + /// + [JsonProperty("name")] + public string Name { get; } + + /// + /// The value to set for the CSS media feature (e.g., "dark", "reduce"). + /// + [JsonProperty("value")] + public string Value { get; } + + private EmulatedMediaFeature(string name, string value) + { + Name = name; + Value = value; + } + + /// + /// Creates a validated emulated media feature. + /// + /// CSS media feature name (e.g., "prefers-color-scheme"). + /// CSS media feature value (e.g., "dark"). + /// Thrown when name or value is null or whitespace. + public static EmulatedMediaFeature Create(string name, string value) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Media feature name must not be null or empty.", nameof(name)); + + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Media feature value must not be null or empty.", nameof(value)); + + return new EmulatedMediaFeature(name, value); + } + + private static readonly string[] ValidColorSchemes = { "light", "dark" }; + private static readonly string[] ValidReducedMotionValues = { "no-preference", "reduce" }; + + /// + /// Creates a "prefers-color-scheme" media feature. + /// + /// The color scheme value: "light" or "dark". + /// Thrown when scheme is not "light" or "dark". + public static EmulatedMediaFeature PrefersColorScheme(string scheme) + { + if (!ValidColorSchemes.Contains(scheme, StringComparer.Ordinal)) + throw new ArgumentException( + $"prefers-color-scheme must be 'light' or 'dark', but got '{scheme}'.", + nameof(scheme)); + + return Create("prefers-color-scheme", scheme); + } + + /// + /// Creates a "prefers-reduced-motion" media feature. + /// + /// The value: "no-preference" or "reduce". + /// Thrown when value is not "no-preference" or "reduce". + public static EmulatedMediaFeature PrefersReducedMotion(string value) + { + if (!ValidReducedMotionValues.Contains(value, StringComparer.Ordinal)) + throw new ArgumentException( + $"prefers-reduced-motion must be 'no-preference' or 'reduce', but got '{value}'.", + nameof(value)); + + return Create("prefers-reduced-motion", value); + } +} diff --git a/src/Gotenberg.Sharp.Api.Client/Domain/ValueObjects/GotenbergStatusCode.cs b/src/Gotenberg.Sharp.Api.Client/Domain/ValueObjects/GotenbergStatusCode.cs new file mode 100644 index 0000000..c86065d --- /dev/null +++ b/src/Gotenberg.Sharp.Api.Client/Domain/ValueObjects/GotenbergStatusCode.cs @@ -0,0 +1,73 @@ +// Copyright 2019-2026 Chris Mohan, Jaben Cargman +// and GotenbergSharpApiClient Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Globalization; + +namespace Gotenberg.Sharp.API.Client.Domain.ValueObjects; + +/// +/// Represents a validated HTTP status code used for Gotenberg's failOnHttpStatusCodes +/// and failOnResourceHttpStatusCodes fields. Values must be in the 100-599 range. +/// +/// +/// Gotenberg uses these as range boundaries. For example, [499, 599] means +/// "fail on any status code from 499 to 599 inclusive." +/// +public sealed class GotenbergStatusCode : IEquatable, IComparable +{ + public const int MinValue = 100; + public const int MaxValue = 599; + + public int Value { get; } + + private GotenbergStatusCode(int value) + { + Value = value; + } + + /// + /// Creates a validated HTTP status code. + /// + /// An HTTP status code between 100 and 599 inclusive. + /// Thrown when statusCode is outside valid range. + public static GotenbergStatusCode Create(int statusCode) + { + if (statusCode < MinValue || statusCode > MaxValue) + throw new ArgumentOutOfRangeException( + nameof(statusCode), + statusCode, + $"HTTP status code must be between {MinValue} and {MaxValue}."); + + return new GotenbergStatusCode(statusCode); + } + + public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); + + public bool Equals(GotenbergStatusCode? other) => other is not null && Value == other.Value; + + public override bool Equals(object? obj) => Equals(obj as GotenbergStatusCode); + + public override int GetHashCode() => Value; + + public int CompareTo(GotenbergStatusCode? other) => other is null ? 1 : Value.CompareTo(other.Value); + + /// Thrown when is null. + public static implicit operator int(GotenbergStatusCode code) => + code?.Value ?? throw new ArgumentNullException(nameof(code)); + + public static bool operator ==(GotenbergStatusCode? left, GotenbergStatusCode? right) => Equals(left, right); + + public static bool operator !=(GotenbergStatusCode? left, GotenbergStatusCode? right) => !Equals(left, right); +} diff --git a/src/Gotenberg.Sharp.Api.Client/Infrastructure/Constants.cs b/src/Gotenberg.Sharp.Api.Client/Infrastructure/Constants.cs index 3636f5b..8487b7d 100644 --- a/src/Gotenberg.Sharp.Api.Client/Infrastructure/Constants.cs +++ b/src/Gotenberg.Sharp.Api.Client/Infrastructure/Constants.cs @@ -383,6 +383,8 @@ public static class HtmlConvert public const string WaitForExpression = "waitForExpression"; + public const string WaitForSelector = "waitForSelector"; + //http headers public const string UserAgent = "userAgent"; @@ -398,10 +400,21 @@ public static class HtmlConvert //css public const string EmulatedMediaType = "emulatedMediaType"; + public const string EmulatedMediaFeatures = "emulatedMediaFeatures"; + public const string SkipNetworkIdleEvent = "skipNetworkIdleEvent"; public const string GenerateTaggedPdf = "generateTaggedPdf"; + //error handling + public const string FailOnHttpStatusCodes = "failOnHttpStatusCodes"; + + public const string FailOnResourceHttpStatusCodes = "failOnResourceHttpStatusCodes"; + + public const string IgnoreResourceHttpStatusDomains = "ignoreResourceHttpStatusDomains"; + + public const string FailOnResourceLoadingFailed = "failOnResourceLoadingFailed"; + //pdf format public const string PdfFormat = CrossCutting.PdfFormat; diff --git a/test/GotenbergSharpClient.Tests/ChromiumMissingFieldsIntegrationTests.cs b/test/GotenbergSharpClient.Tests/ChromiumMissingFieldsIntegrationTests.cs new file mode 100644 index 0000000..09f89de --- /dev/null +++ b/test/GotenbergSharpClient.Tests/ChromiumMissingFieldsIntegrationTests.cs @@ -0,0 +1,138 @@ +using Gotenberg.Sharp.API.Client.Domain.Builders; +using Gotenberg.Sharp.API.Client.Domain.Settings; +using Gotenberg.Sharp.API.Client.Extensions; +using Gotenberg.Sharp.API.Client.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace GotenbergSharpClient.Tests; + +[TestFixture] +public class ChromiumMissingFieldsIntegrationTests +{ + private const string GotenbergUrl = "http://localhost:3000"; + private const string TestUsername = "testuser"; + private const string TestPassword = "testpass"; + + private Gotenberg.Sharp.API.Client.GotenbergSharpClient _client = null!; + + [SetUp] + public void SetUp() + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(options => + { + options.ServiceUrl = new Uri(GotenbergUrl); + options.BasicAuthUsername = TestUsername; + options.BasicAuthPassword = TestPassword; + }); + + services.AddGotenbergSharpClient(); + + var serviceProvider = services.BuildServiceProvider(); + _client = serviceProvider.GetRequiredService(); + } + + [Category("Integration")] + [Test] + public async Task HtmlToPdf_WithWaitForSelector_Succeeds() + { + var builder = new HtmlRequestBuilder() + .AddDocument(doc => doc.SetBody( + "
Hello
")) + .SetConversionBehaviors(b => b + .SetWaitForSelector("#ready")); + + var result = await _client.HtmlToPdfAsync(builder); + + result.Should().NotBeNull(); + result.Length.Should().BeGreaterThan(0); + } + + [Category("Integration")] + [Test] + public async Task HtmlToPdf_WithEmulatedMediaFeatures_Succeeds() + { + var builder = new HtmlRequestBuilder() + .AddDocument(doc => doc.SetBody( + "

Dark mode test

")) + .SetConversionBehaviors(b => b + .AddEmulatedMediaFeature("prefers-color-scheme", "dark")); + + var result = await _client.HtmlToPdfAsync(builder); + + result.Should().NotBeNull(); + result.Length.Should().BeGreaterThan(0); + } + + [Category("Integration")] + [Test] + public async Task HtmlToPdf_WithFailOnHttpStatusCodes_Succeeds() + { + var builder = new HtmlRequestBuilder() + .AddDocument(doc => doc.SetBody( + "

Status code test

")) + .SetConversionBehaviors(b => b + .SetFailOnHttpStatusCodes(499, 599)); + + var result = await _client.HtmlToPdfAsync(builder); + + result.Should().NotBeNull(); + result.Length.Should().BeGreaterThan(0); + } + + [Category("Integration")] + [Test] + public async Task HtmlToPdf_WithFailOnResourceLoadingFailed_WhenResourceFails_Throws() + { + var builder = new HtmlRequestBuilder() + .AddDocument(doc => doc.SetBody( + "")) + .SetConversionBehaviors(b => b + .FailOnResourceLoadingFailed()); + + var act = async () => await _client.HtmlToPdfAsync(builder); + + await act.Should().ThrowAsync(); + } + + [Category("Integration")] + [Test] + public async Task HtmlToPdf_WithFailOnResourceLoadingFailed_Succeeds() + { + var builder = new HtmlRequestBuilder() + .AddDocument(doc => doc.SetBody( + "

Resource loading test

")) + .SetConversionBehaviors(b => b + .FailOnResourceLoadingFailed()); + + var result = await _client.HtmlToPdfAsync(builder); + + result.Should().NotBeNull(); + result.Length.Should().BeGreaterThan(0); + } + + [Category("Integration")] + [Test] + public async Task HtmlToPdf_WithAllNewFields_Succeeds() + { + var builder = new HtmlRequestBuilder() + .AddDocument(doc => doc.SetBody( + "
Combined test
")) + .SetConversionBehaviors(b => b + .SetWaitForSelector("#app") + .AddEmulatedMediaFeature("prefers-color-scheme", "dark") + .AddEmulatedMediaFeature("prefers-reduced-motion", "reduce") + .SetFailOnHttpStatusCodes(499, 599) + .SetFailOnResourceHttpStatusCodes(400, 500) + .AddIgnoreResourceHttpStatusDomains("cdn.example.com", "fonts.googleapis.com") + .FailOnResourceLoadingFailed() + .FailOnConsoleExceptions()); + + var result = await _client.HtmlToPdfAsync(builder); + + result.Should().NotBeNull(); + result.Length.Should().BeGreaterThan(0); + } +} diff --git a/test/GotenbergSharpClient.Tests/ChromiumMissingFieldsTests.cs b/test/GotenbergSharpClient.Tests/ChromiumMissingFieldsTests.cs new file mode 100644 index 0000000..7c6acb5 --- /dev/null +++ b/test/GotenbergSharpClient.Tests/ChromiumMissingFieldsTests.cs @@ -0,0 +1,289 @@ +using Gotenberg.Sharp.API.Client.Domain.Builders; +using Gotenberg.Sharp.API.Client.Domain.Builders.Faceted; +using Gotenberg.Sharp.API.Client.Domain.Requests; +using Gotenberg.Sharp.API.Client.Domain.Requests.Facets; +using Gotenberg.Sharp.API.Client.Domain.ValueObjects; +using Newtonsoft.Json.Linq; + +namespace GotenbergSharpClient.Tests; + +[TestFixture] +public class ChromiumMissingFieldsTests +{ + #region Builder Tests + + [Test] + public void SetWaitForSelector_WithString_SetsProperty() + { + var builder = new HtmlRequestBuilder(); + + builder.SetConversionBehaviors(b => b.SetWaitForSelector("#content")); + var request = builder.Build(); + + request.ConversionBehaviors.WaitForSelector.Should().NotBeNull(); + request.ConversionBehaviors.WaitForSelector!.Value.Should().Be("#content"); + } + + [Test] + public void SetWaitForSelector_WithCssSelector_SetsProperty() + { + var builder = new HtmlRequestBuilder(); + var selector = CssSelector.Create(".loaded"); + + builder.SetConversionBehaviors(b => b.SetWaitForSelector(selector)); + var request = builder.Build(); + + request.ConversionBehaviors.WaitForSelector.Should().Be(selector); + } + + [Test] + public void SetWaitForSelector_WithEmptyString_ThrowsArgumentException() + { + var builder = new HtmlRequestBuilder(); + + var act = () => builder.SetConversionBehaviors(b => b.SetWaitForSelector("")); + + act.Should().ThrowExactly(); + } + + [Test] + public void AddEmulatedMediaFeature_WithNameAndValue_AddsToList() + { + var builder = new HtmlRequestBuilder(); + + builder.SetConversionBehaviors(b => b + .AddEmulatedMediaFeature("prefers-color-scheme", "dark") + .AddEmulatedMediaFeature("prefers-reduced-motion", "reduce")); + var request = builder.Build(); + + request.ConversionBehaviors.EmulatedMediaFeatures.Should().HaveCount(2); + } + + [Test] + public void AddEmulatedMediaFeature_WithEmptyName_ThrowsArgumentException() + { + var builder = new HtmlRequestBuilder(); + + var act = () => builder.SetConversionBehaviors(b => b.AddEmulatedMediaFeature("", "dark")); + + act.Should().ThrowExactly(); + } + + [Test] + public void SetFailOnHttpStatusCodes_WithIntParams_SetsProperty() + { + var builder = new HtmlRequestBuilder(); + + builder.SetConversionBehaviors(b => b.SetFailOnHttpStatusCodes(499, 599)); + var request = builder.Build(); + + request.ConversionBehaviors.FailOnHttpStatusCodes.Should().HaveCount(2); + request.ConversionBehaviors.FailOnHttpStatusCodes![0].Value.Should().Be(499); + request.ConversionBehaviors.FailOnHttpStatusCodes![1].Value.Should().Be(599); + } + + [Test] + public void SetFailOnHttpStatusCodes_WithInvalidCode_ThrowsArgumentOutOfRangeException() + { + var builder = new HtmlRequestBuilder(); + + var act = () => builder.SetConversionBehaviors(b => b.SetFailOnHttpStatusCodes(999)); + + act.Should().ThrowExactly(); + } + + [Test] + public void SetFailOnResourceHttpStatusCodes_WithIntParams_SetsProperty() + { + var builder = new HtmlRequestBuilder(); + + builder.SetConversionBehaviors(b => b.SetFailOnResourceHttpStatusCodes(400, 500)); + var request = builder.Build(); + + request.ConversionBehaviors.FailOnResourceHttpStatusCodes.Should().HaveCount(2); + } + + [Test] + public void AddIgnoreResourceHttpStatusDomain_WithString_AddsToDomainList() + { + var builder = new HtmlRequestBuilder(); + + builder.SetConversionBehaviors(b => b + .AddIgnoreResourceHttpStatusDomain("cdn.example.com") + .AddIgnoreResourceHttpStatusDomain("fonts.googleapis.com")); + var request = builder.Build(); + + request.ConversionBehaviors.IgnoreResourceHttpStatusDomains.Should().HaveCount(2); + } + + [Test] + public void AddIgnoreResourceHttpStatusDomains_WithParamsArray_AddsAll() + { + var builder = new HtmlRequestBuilder(); + + builder.SetConversionBehaviors(b => b + .AddIgnoreResourceHttpStatusDomains("cdn.example.com", "fonts.googleapis.com", "analytics.example.com")); + var request = builder.Build(); + + request.ConversionBehaviors.IgnoreResourceHttpStatusDomains.Should().HaveCount(3); + } + + [Test] + public void FailOnResourceLoadingFailed_SetsPropertyToTrue() + { + var builder = new HtmlRequestBuilder(); + + builder.SetConversionBehaviors(b => b.FailOnResourceLoadingFailed()); + var request = builder.Build(); + + request.ConversionBehaviors.FailOnResourceLoadingFailed.Should().BeTrue(); + } + + #endregion + + #region HTTP Content Serialization Tests + + [Test] + public async Task WaitForSelector_SerializesToCorrectHttpContent() + { + var behaviors = new HtmlConversionBehaviors + { + WaitForSelector = CssSelector.Create("#content") + }; + + var httpContents = behaviors.ToHttpContent().ToList(); + var content = httpContents.FirstOrDefault(c => + c.Headers.ContentDisposition?.Name == "waitForSelector"); + + content.Should().NotBeNull(); + (await content!.ReadAsStringAsync()).Should().Be("#content"); + } + + [Test] + public async Task EmulatedMediaFeatures_SerializesToCorrectJsonObject() + { + var behaviors = new HtmlConversionBehaviors + { + EmulatedMediaFeatures = new List + { + EmulatedMediaFeature.PrefersColorScheme("dark"), + EmulatedMediaFeature.PrefersReducedMotion("reduce") + } + }; + + var httpContents = behaviors.ToHttpContent().ToList(); + var content = httpContents.FirstOrDefault(c => + c.Headers.ContentDisposition?.Name == "emulatedMediaFeatures"); + + content.Should().NotBeNull(); + var json = await content!.ReadAsStringAsync(); + var jObject = JObject.Parse(json); + + jObject.Should().HaveCount(2); + jObject["prefers-color-scheme"]!.Value().Should().Be("dark"); + jObject["prefers-reduced-motion"]!.Value().Should().Be("reduce"); + } + + [Test] + public async Task FailOnHttpStatusCodes_SerializesToIntArray() + { + var behaviors = new HtmlConversionBehaviors + { + FailOnHttpStatusCodes = new List + { + GotenbergStatusCode.Create(499), + GotenbergStatusCode.Create(599) + } + }; + + var httpContents = behaviors.ToHttpContent().ToList(); + var content = httpContents.FirstOrDefault(c => + c.Headers.ContentDisposition?.Name == "failOnHttpStatusCodes"); + + content.Should().NotBeNull(); + var json = await content!.ReadAsStringAsync(); + var jArray = JArray.Parse(json); + + jArray.Should().HaveCount(2); + jArray[0].Value().Should().Be(499); + jArray[1].Value().Should().Be(599); + } + + [Test] + public async Task FailOnResourceHttpStatusCodes_SerializesToIntArray() + { + var behaviors = new HtmlConversionBehaviors + { + FailOnResourceHttpStatusCodes = new List + { + GotenbergStatusCode.Create(400), + GotenbergStatusCode.Create(500) + } + }; + + var httpContents = behaviors.ToHttpContent().ToList(); + var content = httpContents.FirstOrDefault(c => + c.Headers.ContentDisposition?.Name == "failOnResourceHttpStatusCodes"); + + content.Should().NotBeNull(); + var json = await content!.ReadAsStringAsync(); + var jArray = JArray.Parse(json); + + jArray.Should().HaveCount(2); + jArray[0].Value().Should().Be(400); + jArray[1].Value().Should().Be(500); + } + + [Test] + public async Task IgnoreResourceHttpStatusDomains_SerializesToStringArray() + { + var behaviors = new HtmlConversionBehaviors + { + IgnoreResourceHttpStatusDomains = new List + { + DomainName.Create("cdn.example.com"), + DomainName.Create("fonts.googleapis.com") + } + }; + + var httpContents = behaviors.ToHttpContent().ToList(); + var content = httpContents.FirstOrDefault(c => + c.Headers.ContentDisposition?.Name == "ignoreResourceHttpStatusDomains"); + + content.Should().NotBeNull(); + var json = await content!.ReadAsStringAsync(); + var jArray = JArray.Parse(json); + + jArray.Should().HaveCount(2); + jArray[0].Value().Should().Be("cdn.example.com"); + jArray[1].Value().Should().Be("fonts.googleapis.com"); + } + + [Test] + public async Task FailOnResourceLoadingFailed_SerializesToHttpContent() + { + var behaviors = new HtmlConversionBehaviors + { + FailOnResourceLoadingFailed = true + }; + + var httpContents = behaviors.ToHttpContent().ToList(); + var content = httpContents.FirstOrDefault(c => + c.Headers.ContentDisposition?.Name == "failOnResourceLoadingFailed"); + + content.Should().NotBeNull(); + (await content!.ReadAsStringAsync()).Should().Be("True"); + } + + [Test] + public void NullFields_AreNotIncludedInHttpContent() + { + var behaviors = new HtmlConversionBehaviors(); + + var httpContents = behaviors.ToHttpContent().ToList(); + + httpContents.Should().BeEmpty("All nullable fields are null, no content should be generated"); + } + + #endregion +} diff --git a/test/GotenbergSharpClient.Tests/ValueObjects/CssSelectorTests.cs b/test/GotenbergSharpClient.Tests/ValueObjects/CssSelectorTests.cs new file mode 100644 index 0000000..c841d6f --- /dev/null +++ b/test/GotenbergSharpClient.Tests/ValueObjects/CssSelectorTests.cs @@ -0,0 +1,65 @@ +using Gotenberg.Sharp.API.Client.Domain.ValueObjects; + +namespace GotenbergSharpClient.Tests.ValueObjects; + +[TestFixture] +public class CssSelectorTests +{ + [Test] + public void Create_WithValidSelector_ReturnsInstance() + { + var selector = CssSelector.Create("#content"); + + selector.Value.Should().Be("#content"); + selector.ToString().Should().Be("#content"); + } + + [TestCase(".loaded")] + [TestCase("[data-ready]")] + [TestCase("div > span.highlight")] + [TestCase("#app .container:nth-child(2)")] + public void Create_WithVariousSelectors_AcceptsAll(string input) + { + var selector = CssSelector.Create(input); + + selector.Value.Should().Be(input); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Create_WithNullOrEmpty_ThrowsArgumentException(string? input) + { + var act = () => CssSelector.Create(input!); + + act.Should().ThrowExactly(); + } + + [Test] + public void ImplicitConversion_ToStringReturnsValue() + { + CssSelector selector = CssSelector.Create("#test"); + string result = selector; + + result.Should().Be("#test"); + } + + [Test] + public void Equals_WithSameValue_ReturnsTrue() + { + var a = CssSelector.Create("#test"); + var b = CssSelector.Create("#test"); + + a.Should().Be(b); + (a == b).Should().BeTrue(); + } + + [Test] + public void Equals_WithDifferentValue_ReturnsFalse() + { + var a = CssSelector.Create("#test"); + var b = CssSelector.Create(".test"); + + a.Should().NotBe(b); + } +} diff --git a/test/GotenbergSharpClient.Tests/ValueObjects/DomainNameTests.cs b/test/GotenbergSharpClient.Tests/ValueObjects/DomainNameTests.cs new file mode 100644 index 0000000..047c76c --- /dev/null +++ b/test/GotenbergSharpClient.Tests/ValueObjects/DomainNameTests.cs @@ -0,0 +1,52 @@ +using Gotenberg.Sharp.API.Client.Domain.ValueObjects; + +namespace GotenbergSharpClient.Tests.ValueObjects; + +[TestFixture] +public class DomainNameTests +{ + [Test] + public void Create_WithValidDomain_ReturnsInstance() + { + var domain = DomainName.Create("cdn.example.com"); + + domain.Value.Should().Be("cdn.example.com"); + } + + [Test] + public void Create_TrimsWhitespace() + { + var domain = DomainName.Create(" cdn.example.com "); + + domain.Value.Should().Be("cdn.example.com"); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Create_WithNullOrEmpty_ThrowsArgumentException(string? input) + { + var act = () => DomainName.Create(input!); + + act.Should().ThrowExactly(); + } + + [Test] + public void Equals_IsCaseInsensitive() + { + var a = DomainName.Create("CDN.Example.COM"); + var b = DomainName.Create("cdn.example.com"); + + a.Should().Be(b); + (a == b).Should().BeTrue(); + } + + [Test] + public void ImplicitConversion_ToStringReturnsValue() + { + var domain = DomainName.Create("cdn.example.com"); + string result = domain; + + result.Should().Be("cdn.example.com"); + } +} diff --git a/test/GotenbergSharpClient.Tests/ValueObjects/EmulatedMediaFeatureTests.cs b/test/GotenbergSharpClient.Tests/ValueObjects/EmulatedMediaFeatureTests.cs new file mode 100644 index 0000000..6c20f7d --- /dev/null +++ b/test/GotenbergSharpClient.Tests/ValueObjects/EmulatedMediaFeatureTests.cs @@ -0,0 +1,80 @@ +using Gotenberg.Sharp.API.Client.Domain.ValueObjects; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GotenbergSharpClient.Tests.ValueObjects; + +[TestFixture] +public class EmulatedMediaFeatureTests +{ + [Test] + public void Create_WithValidNameAndValue_ReturnsInstance() + { + var feature = EmulatedMediaFeature.Create("prefers-color-scheme", "dark"); + + feature.Name.Should().Be("prefers-color-scheme"); + feature.Value.Should().Be("dark"); + } + + [TestCase(null, "dark")] + [TestCase("", "dark")] + [TestCase(" ", "dark")] + [TestCase("prefers-color-scheme", null)] + [TestCase("prefers-color-scheme", "")] + [TestCase("prefers-color-scheme", " ")] + public void Create_WithNullOrEmptyNameOrValue_ThrowsArgumentException(string? name, string? value) + { + var act = () => EmulatedMediaFeature.Create(name!, value!); + + act.Should().ThrowExactly(); + } + + [Test] + public void PrefersColorScheme_CreatesCorrectFeature() + { + var feature = EmulatedMediaFeature.PrefersColorScheme("dark"); + + feature.Name.Should().Be("prefers-color-scheme"); + feature.Value.Should().Be("dark"); + } + + [Test] + public void PrefersReducedMotion_CreatesCorrectFeature() + { + var feature = EmulatedMediaFeature.PrefersReducedMotion("reduce"); + + feature.Name.Should().Be("prefers-reduced-motion"); + feature.Value.Should().Be("reduce"); + } + + [Test] + public void Serialization_ProducesCorrectJson() + { + var feature = EmulatedMediaFeature.Create("prefers-color-scheme", "dark"); + + var json = JsonConvert.SerializeObject(feature); + var jObject = JObject.Parse(json); + + jObject["name"]!.Value().Should().Be("prefers-color-scheme"); + jObject["value"]!.Value().Should().Be("dark"); + } + + [Test] + public void ListSerialization_ProducesCorrectJsonArray() + { + var features = new List + { + EmulatedMediaFeature.PrefersColorScheme("dark"), + EmulatedMediaFeature.PrefersReducedMotion("reduce") + }; + + var json = JsonConvert.SerializeObject(features); + var jArray = JArray.Parse(json); + + jArray.Should().HaveCount(2); + jArray[0]["name"]!.Value().Should().Be("prefers-color-scheme"); + jArray[0]["value"]!.Value().Should().Be("dark"); + jArray[1]["name"]!.Value().Should().Be("prefers-reduced-motion"); + jArray[1]["value"]!.Value().Should().Be("reduce"); + } +} diff --git a/test/GotenbergSharpClient.Tests/ValueObjects/GotenbergStatusCodeTests.cs b/test/GotenbergSharpClient.Tests/ValueObjects/GotenbergStatusCodeTests.cs new file mode 100644 index 0000000..71d2848 --- /dev/null +++ b/test/GotenbergSharpClient.Tests/ValueObjects/GotenbergStatusCodeTests.cs @@ -0,0 +1,79 @@ +using Gotenberg.Sharp.API.Client.Domain.ValueObjects; + +namespace GotenbergSharpClient.Tests.ValueObjects; + +[TestFixture] +public class GotenbergStatusCodeTests +{ + [TestCase(100)] + [TestCase(200)] + [TestCase(404)] + [TestCase(499)] + [TestCase(599)] + public void Create_WithValidStatusCode_ReturnsInstance(int code) + { + var statusCode = GotenbergStatusCode.Create(code); + + statusCode.Value.Should().Be(code); + } + + [TestCase(99)] + [TestCase(0)] + [TestCase(-1)] + [TestCase(600)] + [TestCase(1000)] + public void Create_WithOutOfRangeCode_ThrowsArgumentOutOfRangeException(int code) + { + var act = () => GotenbergStatusCode.Create(code); + + act.Should().ThrowExactly(); + } + + [Test] + public void ImplicitConversion_ToIntReturnsValue() + { + var statusCode = GotenbergStatusCode.Create(404); + int result = statusCode; + + result.Should().Be(404); + } + + [Test] + public void ImplicitConversion_WithNull_Throws() + { + GotenbergStatusCode? nullCode = null; + + var act = () => { int _ = nullCode!; }; + + act.Should().ThrowExactly(); + } + + [Test] + public void Equals_WithSameValue_ReturnsTrue() + { + var a = GotenbergStatusCode.Create(499); + var b = GotenbergStatusCode.Create(499); + + a.Should().Be(b); + (a == b).Should().BeTrue(); + } + + [Test] + public void CompareTo_OrdersCorrectly() + { + var low = GotenbergStatusCode.Create(200); + var high = GotenbergStatusCode.Create(500); + + low.CompareTo(high).Should().BeNegative(); + high.CompareTo(low).Should().BePositive(); + low.CompareTo(low).Should().Be(0); + } + + [Test] + public void ToString_ReturnsInvariantString() + { + var statusCode = GotenbergStatusCode.Create(404); + + statusCode.ToString().Should().Be("404"); + } +}