diff --git a/Directory.Packages.props b/Directory.Packages.props index deb03b526..40a57e8b4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,7 +29,7 @@ - + diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs b/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs index 2aba29981..978a1f1fe 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.Collections; +using System.Text; using System.Text.Json.Serialization; using Elastic.Documentation.Diagnostics; using YamlDotNet.Serialization; @@ -34,6 +35,7 @@ public interface IApplicableToElement } [YamlSerializable] +[JsonConverter(typeof(ApplicableToJsonConverter))] public record ApplicableTo { [YamlMember(Alias = "stack")] @@ -61,6 +63,58 @@ public record ApplicableTo Deployment = DeploymentApplicability.All, Product = AppliesCollection.GenerallyAvailable }; + + public static ApplicableTo Default { get; } = new() + { + Stack = new AppliesCollection([new Applicability { Version = new SemVersion(9, 0, 0), Lifecycle = ProductLifecycle.GenerallyAvailable }]), + Serverless = ServerlessProjectApplicability.All + }; + + /// + public override string ToString() + { + var sb = new StringBuilder(); + var hasContent = false; + + if (Stack is not null) + { + _ = sb.Append("stack: ").Append(Stack); + hasContent = true; + } + + if (Deployment is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("deployment: ").Append(Deployment); + hasContent = true; + } + + if (Serverless is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("serverless: ").Append(Serverless); + hasContent = true; + } + + if (Product is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("product: ").Append(Product); + hasContent = true; + } + + if (ProductApplicability is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("products: ").Append(ProductApplicability); + } + + return sb.ToString(); + } } [YamlSerializable] @@ -85,6 +139,44 @@ public record DeploymentApplicability Ess = AppliesCollection.GenerallyAvailable, Self = AppliesCollection.GenerallyAvailable }; + + /// + public override string ToString() + { + var sb = new StringBuilder(); + var hasContent = false; + + if (Self is not null) + { + _ = sb.Append("self=").Append(Self); + hasContent = true; + } + + if (Ece is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("ece=").Append(Ece); + hasContent = true; + } + + if (Eck is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("eck=").Append(Eck); + hasContent = true; + } + + if (Ess is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("ess=").Append(Ess); + } + + return sb.ToString(); + } } [YamlSerializable] @@ -113,6 +205,36 @@ public record ServerlessProjectApplicability Observability = AppliesCollection.GenerallyAvailable, Security = AppliesCollection.GenerallyAvailable }; + + /// + public override string ToString() + { + var sb = new StringBuilder(); + var hasContent = false; + + if (Elasticsearch is not null) + { + _ = sb.Append("elasticsearch=").Append(Elasticsearch); + hasContent = true; + } + + if (Observability is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("observability=").Append(Observability); + hasContent = true; + } + + if (Security is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("security=").Append(Security); + } + + return sb.ToString(); + } } [YamlSerializable] @@ -183,4 +305,46 @@ public record ProductApplicability [YamlMember(Alias = "edot-collector")] public AppliesCollection? EdotCollector { get; set; } + + /// + public override string ToString() + { + var sb = new StringBuilder(); + var hasContent = false; + + void AppendProduct(string name, AppliesCollection? value) + { + if (value is null) + return; + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append(name).Append('=').Append(value); + hasContent = true; + } + + AppendProduct("ecctl", Ecctl); + AppendProduct("curator", Curator); + AppendProduct("apm-agent-android", ApmAgentAndroid); + AppendProduct("apm-agent-dotnet", ApmAgentDotnet); + AppendProduct("apm-agent-go", ApmAgentGo); + AppendProduct("apm-agent-ios", ApmAgentIos); + AppendProduct("apm-agent-java", ApmAgentJava); + AppendProduct("apm-agent-node", ApmAgentNode); + AppendProduct("apm-agent-php", ApmAgentPhp); + AppendProduct("apm-agent-python", ApmAgentPython); + AppendProduct("apm-agent-ruby", ApmAgentRuby); + AppendProduct("apm-agent-rum-js", ApmAgentRumJs); + AppendProduct("edot-ios", EdotIos); + AppendProduct("edot-android", EdotAndroid); + AppendProduct("edot-dotnet", EdotDotnet); + AppendProduct("edot-java", EdotJava); + AppendProduct("edot-node", EdotNode); + AppendProduct("edot-php", EdotPhp); + AppendProduct("edot-python", EdotPython); + AppendProduct("edot-cf-aws", EdotCfAws); + AppendProduct("edot-cf-azure", EdotCfAzure); + AppendProduct("edot-collector", EdotCollector); + + return sb.ToString(); + } } diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs new file mode 100644 index 000000000..d3779e525 --- /dev/null +++ b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs @@ -0,0 +1,263 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using YamlDotNet.Serialization; + +namespace Elastic.Documentation.AppliesTo; + +/// +/// JSON converter for ApplicableTo that serializes to a flat array of objects with: +/// - type: stack, deployment, serverless, or product +/// - sub-type: the property name (e.g., "self", "ece", "elasticsearch", "ecctl") +/// - lifecycle: the lifecycle value (if applicable) +/// - version: the version value (if applicable) +/// +public class ApplicableToJsonConverter : JsonConverter +{ + public override ApplicableTo? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + + if (reader.TokenType != JsonTokenType.StartArray) + throw new JsonException("Expected array"); + + var result = new ApplicableTo(); + var deploymentProps = new Dictionary>(); + var serverlessProps = new Dictionary>(); + var productProps = new Dictionary>(); + var stackItems = new List(); + var productItems = new List(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + break; + + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException("Expected object"); + + string? type = null; + string? subType = null; + var lifecycle = ProductLifecycle.GenerallyAvailable; + SemVersion? version = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException("Expected property name"); + + var propertyName = reader.GetString(); + _ = reader.Read(); + + switch (propertyName) + { + case "type": + type = reader.GetString(); + break; + case "sub_type": + subType = reader.GetString(); + break; + case "lifecycle": + var lifecycleStr = reader.GetString(); + if (lifecycleStr != null) + lifecycle = ParseLifecycle(lifecycleStr); + break; + case "version": + var versionStr = reader.GetString(); + if (versionStr != null && SemVersionConverter.TryParse(versionStr, out var v)) + version = v; + break; + } + } + + if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(subType)) + throw new JsonException("Missing type or sub-type"); + + var applicability = new Applicability { Lifecycle = lifecycle, Version = version }; + + switch (type) + { + case "stack": + stackItems.Add(applicability); + break; + case "deployment": + if (!deploymentProps.ContainsKey(subType)) + deploymentProps[subType] = []; + deploymentProps[subType].Add(applicability); + break; + case "serverless": + if (!serverlessProps.ContainsKey(subType)) + serverlessProps[subType] = []; + serverlessProps[subType].Add(applicability); + break; + case "product" when subType == "product": + productItems.Add(applicability); + break; + case "product": + if (!productProps.ContainsKey(subType)) + productProps[subType] = []; + productProps[subType].Add(applicability); + break; + } + } + + // Create Stack collection + if (stackItems.Count > 0) + result.Stack = new AppliesCollection(stackItems.ToArray()); + + // Create Product collection + if (productItems.Count > 0) + result.Product = new AppliesCollection(productItems.ToArray()); + + // Reconstruct DeploymentApplicability + if (deploymentProps.Count > 0) + { + result.Deployment = new DeploymentApplicability + { + Self = deploymentProps.TryGetValue("self", out var self) ? new AppliesCollection(self.ToArray()) : null, + Ece = deploymentProps.TryGetValue("ece", out var ece) ? new AppliesCollection(ece.ToArray()) : null, + Eck = deploymentProps.TryGetValue("eck", out var eck) ? new AppliesCollection(eck.ToArray()) : null, + Ess = deploymentProps.TryGetValue("ess", out var ess) ? new AppliesCollection(ess.ToArray()) : null + }; + } + + // Reconstruct ServerlessProjectApplicability + if (serverlessProps.Count > 0) + { + result.Serverless = new ServerlessProjectApplicability + { + Elasticsearch = serverlessProps.TryGetValue("elasticsearch", out var es) ? new AppliesCollection(es.ToArray()) : null, + Observability = serverlessProps.TryGetValue("observability", out var obs) ? new AppliesCollection(obs.ToArray()) : null, + Security = serverlessProps.TryGetValue("security", out var sec) ? new AppliesCollection(sec.ToArray()) : null + }; + } + + // Reconstruct ProductApplicability + if (productProps.Count > 0) + { + var productApplicability = new ProductApplicability(); + var productType = typeof(ProductApplicability); + + foreach (var (key, items) in productProps) + { + // Find the property by YamlMember alias + var property = productType.GetProperties() + .FirstOrDefault(p => p.GetCustomAttribute()?.Alias == key); + + property?.SetValue(productApplicability, new AppliesCollection(items.ToArray())); + } + + result.ProductApplicability = productApplicability; + } + + return result; + } + + public override void Write(Utf8JsonWriter writer, ApplicableTo value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + + // Stack + if (value.Stack != null) + WriteApplicabilityEntries(writer, "stack", "stack", value.Stack); + + // Deployment + if (value.Deployment != null) + { + if (value.Deployment.Self != null) + WriteApplicabilityEntries(writer, "deployment", "self", value.Deployment.Self); + if (value.Deployment.Ece != null) + WriteApplicabilityEntries(writer, "deployment", "ece", value.Deployment.Ece); + if (value.Deployment.Eck != null) + WriteApplicabilityEntries(writer, "deployment", "eck", value.Deployment.Eck); + if (value.Deployment.Ess != null) + WriteApplicabilityEntries(writer, "deployment", "ess", value.Deployment.Ess); + } + + // Serverless + if (value.Serverless != null) + { + if (value.Serverless.Elasticsearch != null) + WriteApplicabilityEntries(writer, "serverless", "elasticsearch", value.Serverless.Elasticsearch); + if (value.Serverless.Observability != null) + WriteApplicabilityEntries(writer, "serverless", "observability", value.Serverless.Observability); + if (value.Serverless.Security != null) + WriteApplicabilityEntries(writer, "serverless", "security", value.Serverless.Security); + } + + // Product (simple) + if (value.Product != null) + WriteApplicabilityEntries(writer, "product", "product", value.Product); + + // ProductApplicability (specific products) + if (value.ProductApplicability != null) + { + var productType = typeof(ProductApplicability); + foreach (var property in productType.GetProperties()) + { + var yamlAlias = property.GetCustomAttribute()?.Alias; + if (yamlAlias != null) + { + if (property.GetValue(value.ProductApplicability) is AppliesCollection propertyValue) + WriteApplicabilityEntries(writer, "product", yamlAlias, propertyValue); + } + } + } + + writer.WriteEndArray(); + } + + private static ProductLifecycle ParseLifecycle(string lifecycleStr) => lifecycleStr.ToLowerInvariant() switch + { + "preview" => ProductLifecycle.TechnicalPreview, + "beta" => ProductLifecycle.Beta, + "ga" => ProductLifecycle.GenerallyAvailable, + "deprecated" => ProductLifecycle.Deprecated, + "removed" => ProductLifecycle.Removed, + "unavailable" => ProductLifecycle.Unavailable, + "development" => ProductLifecycle.Development, + "planned" => ProductLifecycle.Planned, + "discontinued" => ProductLifecycle.Discontinued, + _ => ProductLifecycle.GenerallyAvailable + }; + + private static void WriteApplicabilityEntries(Utf8JsonWriter writer, string type, string subType, AppliesCollection collection) + { + foreach (var applicability in collection) + { + writer.WriteStartObject(); + writer.WriteString("type", type); + writer.WriteString("sub_type", subType); + + // Write lifecycle + var lifecycleName = applicability.Lifecycle switch + { + ProductLifecycle.TechnicalPreview => "preview", + ProductLifecycle.Beta => "beta", + ProductLifecycle.GenerallyAvailable => "ga", + ProductLifecycle.Deprecated => "deprecated", + ProductLifecycle.Removed => "removed", + ProductLifecycle.Unavailable => "unavailable", + ProductLifecycle.Development => "development", + ProductLifecycle.Planned => "planned", + ProductLifecycle.Discontinued => "discontinued", + _ => "ga" + }; + writer.WriteString("lifecycle", lifecycleName); + + // Write the version + if (applicability.Version is not null) + writer.WriteString("version", applicability.Version.ToString()); + + writer.WriteEndObject(); + } + } +} diff --git a/src/Elastic.Documentation/Search/DocumentationDocument.cs b/src/Elastic.Documentation/Search/DocumentationDocument.cs index 8a0eb6781..8470fc3b5 100644 --- a/src/Elastic.Documentation/Search/DocumentationDocument.cs +++ b/src/Elastic.Documentation/Search/DocumentationDocument.cs @@ -4,7 +4,6 @@ using System.Text.Json.Serialization; using Elastic.Documentation.AppliesTo; -using Elastic.Documentation.Extensions; namespace Elastic.Documentation.Search; diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs index 199724929..3068e8f59 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs @@ -93,8 +93,8 @@ Func createOptions options.ExportBufferCallback = () => { var count = Interlocked.Increment(ref i); - _logger.LogInformation("Exported {Count} documents to Elasticsearch index {Format}", - count * endpoint.BufferSize, string.Format(options.IndexFormat, "latest")); + _logger.LogInformation("Exported {Count} documents to Elasticsearch index {IndexName}", + count * endpoint.BufferSize, Channel?.IndexName ?? string.Format(options.IndexFormat, "latest")); }; options.ExportExceptionCallback = e => { @@ -103,7 +103,7 @@ Func createOptions }; options.ServerRejectionCallback = items => _logger.LogInformation("Server rejection: {Rejection}", items.First().Item2); Channel = createChannel(options); - _logger.LogInformation($"Bootstrapping {nameof(SemanticIndexChannel)} Elasticsearch target for indexing"); + _logger.LogInformation("Created {Channel} Elasticsearch target for indexing", typeof(TChannel).Name); } public async ValueTask StopAsync(Cancel ctx = default) @@ -193,6 +193,34 @@ protected static string CreateMapping(string? inferenceId) => "prefix": { "type": "text", "analyzer" : "hierarchy_analyzer" } } }, + "applies_to" : { + "type" : "nested", + "properties" : { + "type" : { "type" : "keyword" }, + "sub-type" : { "type" : "keyword" }, + "lifecycle" : { "type" : "keyword" }, + "version" : { "type" : "version" } + } + }, + "parents" : { + "type" : "object", + "properties" : { + "url" : { + "type": "keyword", + "fields": { + "match": { "type": "text" }, + "prefix": { "type": "text", "analyzer" : "hierarchy_analyzer" } + } + }, + "title": { + "type": "text", + "search_analyzer": "synonyms_analyzer", + "fields": { + "keyword": { "type": "keyword" } + } + } + } + }, "hash" : { "type" : "keyword" }, "title": { "type": "text", diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs index 64c3198e0..bba070ce4 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Search; @@ -33,6 +34,8 @@ public class ElasticsearchMarkdownExporter : IMarkdownExporter, IDisposable private readonly DateTimeOffset _batchIndexDate = DateTimeOffset.UtcNow; private readonly DistributedTransport _transport; private IngestStrategy _indexStrategy; + private string _currentLexicalHash = string.Empty; + private string _currentSemanticHash = string.Empty; public ElasticsearchMarkdownExporter( ILoggerFactory logFactory, @@ -80,26 +83,53 @@ string indexNamespace /// public async ValueTask StartAsync(Cancel ctx = default) { + _currentLexicalHash = await _lexicalChannel.Channel.GetIndexTemplateHashAsync(ctx) ?? string.Empty; + _currentSemanticHash = await _semanticChannel.Channel.GetIndexTemplateHashAsync(ctx) ?? string.Empty; + _ = await _lexicalChannel.Channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, null, ctx); - var semanticIndex = _semanticChannel.Channel.IndexName; - var semanticWriteAlias = string.Format(_semanticChannel.Channel.Options.IndexFormat, "latest"); - var semanticIndexHead = await _transport.HeadAsync(semanticWriteAlias, ctx); - if (!semanticIndexHead.ApiCallDetails.HasSuccessfulStatusCode) + // if the previous hash does not match the current hash, we know already we want to multiplex to a new index + if (_currentLexicalHash != _lexicalChannel.Channel.ChannelHash) + _indexStrategy = IngestStrategy.Multiplex; + + if (!_endpoint.NoSemantic) { - _logger.LogInformation("No semantic index exists yet, creating index {Index} for semantic search", semanticIndex); - _ = await _semanticChannel.Channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, null, ctx); - var semanticIndexPut = await _transport.PutAsync(semanticIndex, PostData.String("{}"), ctx); - if (!semanticIndexPut.ApiCallDetails.HasSuccessfulStatusCode) - throw new Exception($"Failed to create index {semanticIndex}: {semanticIndexPut}"); - _ = await _semanticChannel.Channel.ApplyAliasesAsync(ctx); - if (!_endpoint.ForceReindex) + var semanticWriteAlias = string.Format(_semanticChannel.Channel.Options.IndexFormat, "latest"); + var semanticIndexAvailable = await _transport.HeadAsync(semanticWriteAlias, ctx); + if (!semanticIndexAvailable.ApiCallDetails.HasSuccessfulStatusCode && _endpoint is { ForceReindex: false, NoSemantic: false }) { _indexStrategy = IngestStrategy.Multiplex; - _logger.LogInformation("Index strategy set to multiplex because {SemanticIndex} does not exist, pass --force-reindex to always use reindex", semanticIndex); + _logger.LogInformation("Index strategy set to multiplex because {SemanticIndex} does not exist, pass --force-reindex to always use reindex", semanticWriteAlias); } + + //try re-use index if we are re-indexing. Multiplex should always go to a new index + _semanticChannel.Channel.Options.TryReuseIndex = _indexStrategy == IngestStrategy.Reindex; + _ = await _semanticChannel.Channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, null, ctx); } + + var lexicalIndexExists = await IndexExists(_lexicalChannel.Channel.IndexName) ? "existing" : "new"; + var semanticIndexExists = await IndexExists(_semanticChannel.Channel.IndexName) ? "existing" : "new"; + if (_currentLexicalHash != _lexicalChannel.Channel.ChannelHash) + { + _indexStrategy = IngestStrategy.Multiplex; + _logger.LogInformation("Multiplexing lexical new index: '{Index}' since current hash on server '{HashCurrent}' does not match new '{HashNew}'", + _lexicalChannel.Channel.IndexName, _currentLexicalHash, _lexicalChannel.Channel.ChannelHash); + } + else + _logger.LogInformation("Targeting {State} lexical: '{Index}'", lexicalIndexExists, _lexicalChannel.Channel.IndexName); + + if (!_endpoint.NoSemantic && _currentSemanticHash != _semanticChannel.Channel.ChannelHash) + { + _indexStrategy = IngestStrategy.Multiplex; + _logger.LogInformation("Multiplexing new index '{Index}' since current hash on server '{HashCurrent}' does not match new '{HashNew}'", + _semanticChannel.Channel.IndexName, _currentSemanticHash, _semanticChannel.Channel.ChannelHash); + } + else if (!_endpoint.NoSemantic) + _logger.LogInformation("Targeting {State} semantical: '{Index}'", semanticIndexExists, _semanticChannel.Channel.IndexName); + _logger.LogInformation("Using {IndexStrategy} to sync lexical index to semantic index", _indexStrategy.ToStringFast(true)); + + async ValueTask IndexExists(string name) => (await _transport.HeadAsync(name, ctx)).ApiCallDetails.HasSuccessfulStatusCode; } private async ValueTask CountAsync(string index, string body, Cancel ctx = default) @@ -113,7 +143,6 @@ public async ValueTask StopAsync(Cancel ctx = default) { var semanticWriteAlias = string.Format(_semanticChannel.Channel.Options.IndexFormat, "latest"); var lexicalWriteAlias = string.Format(_lexicalChannel.Channel.Options.IndexFormat, "latest"); - var semanticIndex = _semanticChannel.Channel.IndexName; var stopped = await _lexicalChannel.StopAsync(ctx); if (!stopped) @@ -125,23 +154,28 @@ public async ValueTask StopAsync(Cancel ctx = default) { if (!_endpoint.NoSemantic) _ = await _semanticChannel.StopAsync(ctx); - else - _logger.LogInformation("--no-semantic was specified when doing multiplex writes, not rolling over {SemanticIndex}", semanticIndex); // cleanup lexical index of old data await DoDeleteByQuery(lexicalWriteAlias, ctx); + // need to refresh the lexical index to ensure that the delete by query is available _ = await _lexicalChannel.RefreshAsync(ctx); - _logger.LogInformation("Finish sync to semantic index using {IndexStrategy} strategy", _indexStrategy.ToStringFast(true)); await QueryDocumentCounts(ctx); + // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression + if (_endpoint.NoSemantic) + _logger.LogInformation("Finish indexing {IndexStrategy} strategy", _indexStrategy.ToStringFast(true)); + else + _logger.LogInformation("Finish syncing to semantic in {IndexStrategy} strategy", _indexStrategy.ToStringFast(true)); return; } if (_endpoint.NoSemantic) { - _logger.LogInformation("--no-semantic was specified so exiting early before reindexing to {Index}", semanticIndex); + _logger.LogInformation("--no-semantic was specified so exiting early before reindexing to {Index}", lexicalWriteAlias); return; } + var semanticIndex = _semanticChannel.Channel.IndexName; + // check if the alias exists var semanticIndexHead = await _transport.HeadAsync(semanticWriteAlias, ctx); if (!semanticIndexHead.ApiCallDetails.HasSuccessfulStatusCode) { @@ -150,14 +184,14 @@ public async ValueTask StopAsync(Cancel ctx = default) var semanticIndexPut = await _transport.PutAsync(semanticIndex, PostData.String("{}"), ctx); if (!semanticIndexPut.ApiCallDetails.HasSuccessfulStatusCode) throw new Exception($"Failed to create index {semanticIndex}: {semanticIndexPut}"); - _ = await _semanticChannel.Channel.ApplyAliasesAsync(ctx); } + var destinationIndex = _semanticChannel.Channel.IndexName; - _logger.LogInformation("_reindex updates: '{SourceIndex}' => '{DestinationIndex}'", lexicalWriteAlias, semanticWriteAlias); + _logger.LogInformation("_reindex updates: '{SourceIndex}' => '{DestinationIndex}'", lexicalWriteAlias, destinationIndex); var request = PostData.String(@" { ""dest"": { - ""index"": """ + semanticWriteAlias + @""" + ""index"": """ + destinationIndex + @""" }, ""source"": { ""index"": """ + lexicalWriteAlias + @""", @@ -171,13 +205,13 @@ public async ValueTask StopAsync(Cancel ctx = default) } } }"); - await DoReindex(request, lexicalWriteAlias, semanticWriteAlias, "updates", ctx); + await DoReindex(request, lexicalWriteAlias, destinationIndex, "updates", ctx); - _logger.LogInformation("_reindex deletions: '{SourceIndex}' => '{DestinationIndex}'", lexicalWriteAlias, semanticWriteAlias); + _logger.LogInformation("_reindex deletions: '{SourceIndex}' => '{DestinationIndex}'", lexicalWriteAlias, destinationIndex); request = PostData.String(@" { ""dest"": { - ""index"": """ + semanticWriteAlias + @""" + ""index"": """ + destinationIndex + @""" }, ""script"": { ""source"": ""ctx.op = \""delete\"""" @@ -194,10 +228,13 @@ public async ValueTask StopAsync(Cancel ctx = default) } } }"); - await DoReindex(request, lexicalWriteAlias, semanticWriteAlias, "deletions", ctx); + await DoReindex(request, lexicalWriteAlias, destinationIndex, "deletions", ctx); await DoDeleteByQuery(lexicalWriteAlias, ctx); + _ = await _lexicalChannel.Channel.ApplyLatestAliasAsync(ctx); + _ = await _semanticChannel.Channel.ApplyAliasesAsync(ctx); + _ = await _lexicalChannel.RefreshAsync(ctx); _ = await _semanticChannel.RefreshAsync(ctx); @@ -275,7 +312,7 @@ private async ValueTask DoDeleteByQuery(string lexicalWriteAlias, Cancel ctx) private async ValueTask DoReindex(PostData request, string lexicalWriteAlias, string semanticWriteAlias, string typeOfSync, Cancel ctx) { - var reindexUrl = "/_reindex?wait_for_completion=false&require_alias=true&scroll=10m"; + var reindexUrl = "/_reindex?wait_for_completion=false&scroll=10m"; var reindexNewChanges = await _transport.PostAsync(reindexUrl, request, ctx); var taskId = reindexNewChanges.Body.Get("task"); if (string.IsNullOrWhiteSpace(taskId)) @@ -336,6 +373,10 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, ? body[..Math.Min(body.Length, 400)] + " " + string.Join(" \n- ", headings) : string.Empty; + // this is temporary until https://github.com/elastic/docs-builder/pull/2070 lands + // this PR will add a service for us to resolve to a versioning scheme. + var appliesTo = fileContext.SourceFile.YamlFrontMatter?.AppliesTo ?? ApplicableTo.Default; + var doc = new DocumentationDocument { Url = url, @@ -344,7 +385,7 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, StrippedBody = body.StripMarkdown(), Description = fileContext.SourceFile.YamlFrontMatter?.Description, Abstract = @abstract, - Applies = fileContext.SourceFile.YamlFrontMatter?.AppliesTo, + Applies = appliesTo, UrlSegmentCount = url.Split('/', StringSplitOptions.RemoveEmptyEntries).Length, Parents = navigation.GetParentsOfMarkdownFile(file).Select(i => new ParentDocument { @@ -357,7 +398,7 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, var semanticHash = _semanticChannel.Channel.ChannelHash; var lexicalHash = _lexicalChannel.Channel.ChannelHash; var hash = HashedBulkUpdate.CreateHash(semanticHash, lexicalHash, - doc.Url, doc.Body ?? string.Empty, string.Join(",", doc.Headings.OrderBy(h => h)) + doc.Url, doc.Body ?? string.Empty, string.Join(",", doc.Headings.OrderBy(h => h)), doc.Url ); doc.Hash = hash; doc.LastUpdated = _batchIndexDate; diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs new file mode 100644 index 000000000..3b22299f8 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs @@ -0,0 +1,380 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json; +using Elastic.Documentation; +using Elastic.Documentation.AppliesTo; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.AppliesTo; + +public class ApplicableToJsonConverterRoundTripTests +{ + private readonly JsonSerializerOptions _options = new() { WriteIndented = true }; + + [Fact] + public void RoundTripStackSimple() + { + var original = new ApplicableTo + { + Stack = AppliesCollection.GenerallyAvailable + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().NotBeNull(); + deserialized.Stack.Should().BeEquivalentTo(original.Stack); + } + + [Fact] + public void RoundTripStackWithVersion() + { + var original = new ApplicableTo + { + Stack = new AppliesCollection( + [ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = new SemVersion(8, 0, 0) }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = new SemVersion(7, 17, 0) } + ]) + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().NotBeNull(); + deserialized.Stack.Should().BeEquivalentTo(original.Stack); + } + + [Fact] + public void RoundTripDeploymentAllProperties() + { + var original = new ApplicableTo + { + Deployment = new DeploymentApplicability + { + Self = AppliesCollection.GenerallyAvailable, + Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]), + Eck = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"2.0.0" }]), + Ess = AppliesCollection.GenerallyAvailable + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Deployment.Should().NotBeNull(); + deserialized.Deployment!.Self.Should().BeEquivalentTo(original.Deployment!.Self); + deserialized.Deployment.Ece.Should().BeEquivalentTo(original.Deployment.Ece); + deserialized.Deployment.Eck.Should().BeEquivalentTo(original.Deployment.Eck); + deserialized.Deployment.Ess.Should().BeEquivalentTo(original.Deployment.Ess); + } + + [Fact] + public void RoundTripServerlessAllProperties() + { + var original = new ApplicableTo + { + Serverless = new ServerlessProjectApplicability + { + Elasticsearch = AppliesCollection.GenerallyAvailable, + Observability = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = AllVersions.Instance }]), + Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Serverless.Should().NotBeNull(); + deserialized.Serverless!.Elasticsearch.Should().BeEquivalentTo(original.Serverless!.Elasticsearch); + deserialized.Serverless.Observability.Should().BeEquivalentTo(original.Serverless.Observability); + deserialized.Serverless.Security.Should().BeEquivalentTo(original.Serverless.Security); + } + + [Fact] + public void RoundTripProductSimple() + { + var original = new ApplicableTo + { + Product = AppliesCollection.GenerallyAvailable + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Product.Should().NotBeNull(); + deserialized.Product.Should().BeEquivalentTo(original.Product); + } + + [Fact] + public void RoundTripProductApplicabilitySingleProduct() + { + var original = new ApplicableTo + { + ProductApplicability = new ProductApplicability + { + Ecctl = AppliesCollection.GenerallyAvailable + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.ProductApplicability.Should().NotBeNull(); + deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl); + } + + [Fact] + public void RoundTripProductApplicabilityMultipleProducts() + { + var original = new ApplicableTo + { + ProductApplicability = new ProductApplicability + { + Ecctl = AppliesCollection.GenerallyAvailable, + Curator = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"5.0.0" }]), + ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.2.0" }]), + EdotDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.9.0" }]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.ProductApplicability.Should().NotBeNull(); + deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl); + deserialized.ProductApplicability.Curator.Should().BeEquivalentTo(original.ProductApplicability.Curator); + deserialized.ProductApplicability.ApmAgentDotnet.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentDotnet); + deserialized.ProductApplicability.EdotDotnet.Should().BeEquivalentTo(original.ProductApplicability.EdotDotnet); + } + + [Fact] + public void RoundTripAllProductApplicabilityProperties() + { + var original = new ApplicableTo + { + ProductApplicability = new ProductApplicability + { + Ecctl = AppliesCollection.GenerallyAvailable, + Curator = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"5.0.0" }]), + ApmAgentAndroid = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]), + ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.2.0" }]), + ApmAgentGo = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"2.0.0" }]), + ApmAgentIos = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.5.0" }]), + ApmAgentJava = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.30.0" }]), + ApmAgentNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]), + ApmAgentPhp = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.8.0" }]), + ApmAgentPython = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"6.0.0" }]), + ApmAgentRuby = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"4.0.0" }]), + ApmAgentRumJs = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"5.0.0" }]), + EdotIos = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.9.0" }]), + EdotAndroid = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.8.0" }]), + EdotDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.9.0" }]), + EdotJava = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.7.0" }]), + EdotNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.6.0" }]), + EdotPhp = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.5.0" }]), + EdotPython = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.4.0" }]), + EdotCfAws = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.3.0" }]), + EdotCfAzure = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.2.0" }]), + EdotCollector = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.0.0" }]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.ProductApplicability.Should().NotBeNull(); + deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl); + deserialized.ProductApplicability.Curator.Should().BeEquivalentTo(original.ProductApplicability.Curator); + deserialized.ProductApplicability.ApmAgentAndroid.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentAndroid); + deserialized.ProductApplicability.ApmAgentDotnet.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentDotnet); + deserialized.ProductApplicability.ApmAgentGo.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentGo); + deserialized.ProductApplicability.ApmAgentIos.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentIos); + deserialized.ProductApplicability.ApmAgentJava.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentJava); + deserialized.ProductApplicability.ApmAgentNode.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentNode); + deserialized.ProductApplicability.ApmAgentPhp.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentPhp); + deserialized.ProductApplicability.ApmAgentPython.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentPython); + deserialized.ProductApplicability.ApmAgentRuby.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentRuby); + deserialized.ProductApplicability.ApmAgentRumJs.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentRumJs); + deserialized.ProductApplicability.EdotIos.Should().BeEquivalentTo(original.ProductApplicability.EdotIos); + deserialized.ProductApplicability.EdotAndroid.Should().BeEquivalentTo(original.ProductApplicability.EdotAndroid); + deserialized.ProductApplicability.EdotDotnet.Should().BeEquivalentTo(original.ProductApplicability.EdotDotnet); + deserialized.ProductApplicability.EdotJava.Should().BeEquivalentTo(original.ProductApplicability.EdotJava); + deserialized.ProductApplicability.EdotNode.Should().BeEquivalentTo(original.ProductApplicability.EdotNode); + deserialized.ProductApplicability.EdotPhp.Should().BeEquivalentTo(original.ProductApplicability.EdotPhp); + deserialized.ProductApplicability.EdotPython.Should().BeEquivalentTo(original.ProductApplicability.EdotPython); + deserialized.ProductApplicability.EdotCfAws.Should().BeEquivalentTo(original.ProductApplicability.EdotCfAws); + deserialized.ProductApplicability.EdotCfAzure.Should().BeEquivalentTo(original.ProductApplicability.EdotCfAzure); + deserialized.ProductApplicability.EdotCollector.Should().BeEquivalentTo(original.ProductApplicability.EdotCollector); + } + + [Fact] + public void RoundTripComplexAllFieldsPopulated() + { + var original = new ApplicableTo + { + Stack = new AppliesCollection( + [ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" } + ]), + Deployment = new DeploymentApplicability + { + Self = AppliesCollection.GenerallyAvailable, + Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]), + Eck = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"2.0.0" }]), + Ess = AppliesCollection.GenerallyAvailable + }, + Serverless = new ServerlessProjectApplicability + { + Elasticsearch = AppliesCollection.GenerallyAvailable, + Observability = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = AllVersions.Instance }]), + Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]) + }, + Product = AppliesCollection.GenerallyAvailable, + ProductApplicability = new ProductApplicability + { + Ecctl = AppliesCollection.GenerallyAvailable, + ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.2.0" }]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().BeEquivalentTo(original.Stack); + deserialized.Deployment.Should().NotBeNull(); + deserialized.Deployment!.Self.Should().BeEquivalentTo(original.Deployment!.Self); + deserialized.Deployment.Ece.Should().BeEquivalentTo(original.Deployment.Ece); + deserialized.Deployment.Eck.Should().BeEquivalentTo(original.Deployment.Eck); + deserialized.Deployment.Ess.Should().BeEquivalentTo(original.Deployment.Ess); + deserialized.Serverless.Should().NotBeNull(); + deserialized.Serverless!.Elasticsearch.Should().BeEquivalentTo(original.Serverless!.Elasticsearch); + deserialized.Serverless.Observability.Should().BeEquivalentTo(original.Serverless.Observability); + deserialized.Serverless.Security.Should().BeEquivalentTo(original.Serverless.Security); + deserialized.Product.Should().BeEquivalentTo(original.Product); + deserialized.ProductApplicability.Should().NotBeNull(); + deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl); + deserialized.ProductApplicability.ApmAgentDotnet.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentDotnet); + } + + [Fact] + public void RoundTripAllLifecycles() + { + var lifecycles = Enum.GetValues(); + var applicabilities = lifecycles.Select(lc => + new Applicability { Lifecycle = lc, Version = (SemVersion)"1.0.0" } + ).ToArray(); + + var original = new ApplicableTo + { + Stack = new AppliesCollection(applicabilities) + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().NotBeNull(); + deserialized.Stack.Should().BeEquivalentTo(original.Stack); + } + + [Fact] + public void RoundTripMultipleApplicabilitiesInCollection() + { + var original = new ApplicableTo + { + Stack = new AppliesCollection( + [ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" }, + new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"7.16.0" }, + new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"6.0.0" } + ]) + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().NotBeNull(); + deserialized.Stack.Should().HaveCount(4); + deserialized.Stack.Should().BeEquivalentTo(original.Stack); + } + + [Fact] + public void RoundTripEmptyApplicableTo() + { + var original = new ApplicableTo(); + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().BeNull(); + deserialized.Deployment.Should().BeNull(); + deserialized.Serverless.Should().BeNull(); + deserialized.Product.Should().BeNull(); + deserialized.ProductApplicability.Should().BeNull(); + } + + [Fact] + public void RoundTripNullReturnsNull() + { + ApplicableTo? original = null; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().BeNull(); + } + + [Fact] + public void RoundTripAllVersionsSerializesAsSemanticVersion() + { + var original = new ApplicableTo + { + Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = AllVersions.Instance }]) + }; + + var json = JsonSerializer.Serialize(original, _options); + json.Should().Contain("\"version\": \"9999.9999.9999\""); + + var deserialized = JsonSerializer.Deserialize(json, _options); + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().NotBeNull(); + deserialized.Stack!.First().Version.Should().Be(AllVersions.Instance); + } + + [Fact] + public void RoundTripProductAndProductApplicabilityBothPresent() + { + var original = new ApplicableTo + { + Product = AppliesCollection.GenerallyAvailable, + ProductApplicability = new ProductApplicability + { + Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Product.Should().BeEquivalentTo(original.Product); + deserialized.ProductApplicability.Should().NotBeNull(); + deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl); + } +} diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs new file mode 100644 index 000000000..fbff52703 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs @@ -0,0 +1,394 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json; +using Elastic.Documentation; +using Elastic.Documentation.AppliesTo; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.AppliesTo; + +public class ApplicableToJsonConverterSerializationTests +{ + private readonly JsonSerializerOptions _options = new() + { + WriteIndented = true + }; + + [Fact] + public void SerializeStackProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Stack = AppliesCollection.GenerallyAvailable + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + // language=json + json.Should().Be( + """ + [ + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "ga", + "version": "9999.9999.9999" + } + ] + """); + } + + [Fact] + public void SerializeStackWithVersionProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Stack = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.Beta, + Version = (SemVersion)"8.0.0" + } + ]) + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + // language=json + json.Should().Be( + """ + [ + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "beta", + "version": "8.0.0" + } + ] + """); + } + + [Fact] + public void SerializeMultipleApplicabilitiesProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Stack = new AppliesCollection( + [ + new Applicability + { + Lifecycle = ProductLifecycle.GenerallyAvailable, + Version = (SemVersion)"8.0.0" + }, + new Applicability + { + Lifecycle = ProductLifecycle.Beta, + Version = (SemVersion)"7.17.0" + } + ]) + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + // language=json + json.Should().Be( + """ + [ + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "ga", + "version": "8.0.0" + }, + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "beta", + "version": "7.17.0" + } + ] + """); + } + + [Fact] + public void SerializeDeploymentProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Deployment = new DeploymentApplicability + { + Ece = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.GenerallyAvailable, + Version = (SemVersion)"3.0.0" + } + ]), + Ess = AppliesCollection.GenerallyAvailable + } + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + // language=json + json.Should().Be( + """ + [ + { + "type": "deployment", + "sub_type": "ece", + "lifecycle": "ga", + "version": "3.0.0" + }, + { + "type": "deployment", + "sub_type": "ess", + "lifecycle": "ga", + "version": "9999.9999.9999" + } + ] + """); + } + + [Fact] + public void SerializeServerlessProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Serverless = new ServerlessProjectApplicability + { + Elasticsearch = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.Beta, + Version = (SemVersion)"1.0.0" + } + ]), + Security = AppliesCollection.GenerallyAvailable + } + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + // language=json + json.Should().Be( + """ + [ + { + "type": "serverless", + "sub_type": "elasticsearch", + "lifecycle": "beta", + "version": "1.0.0" + }, + { + "type": "serverless", + "sub_type": "security", + "lifecycle": "ga", + "version": "9999.9999.9999" + } + ] + """); + } + + [Fact] + public void SerializeProductProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Product = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.TechnicalPreview, + Version = (SemVersion)"0.5.0" + } + ]) + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + // language=json + json.Should().Be( + """ + [ + { + "type": "product", + "sub_type": "product", + "lifecycle": "preview", + "version": "0.5.0" + } + ] + """); + } + + [Fact] + public void SerializeProductApplicabilityProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + ProductApplicability = new ProductApplicability + { + Ecctl = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.Deprecated, + Version = (SemVersion)"5.0.0" + } + ]), + ApmAgentDotnet = AppliesCollection.GenerallyAvailable + } + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + // language=json + json.Should().Be( + """ + [ + { + "type": "product", + "sub_type": "ecctl", + "lifecycle": "deprecated", + "version": "5.0.0" + }, + { + "type": "product", + "sub_type": "apm-agent-dotnet", + "lifecycle": "ga", + "version": "9999.9999.9999" + } + ] + """); + } + + [Fact] + public void SerializeAllLifecyclesProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Stack = new AppliesCollection( + [ + new Applicability + { + Lifecycle = ProductLifecycle.TechnicalPreview, + Version = (SemVersion)"1.0.0" + }, + new Applicability + { + Lifecycle = ProductLifecycle.Beta, + Version = (SemVersion)"1.0.0" + }, + new Applicability + { + Lifecycle = ProductLifecycle.GenerallyAvailable, + Version = (SemVersion)"1.0.0" + }, + new Applicability + { + Lifecycle = ProductLifecycle.Deprecated, + Version = (SemVersion)"1.0.0" + }, + new Applicability + { + Lifecycle = ProductLifecycle.Removed, + Version = (SemVersion)"1.0.0" + } + ]) + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + json.Should().Contain("\"lifecycle\": \"preview\""); + json.Should().Contain("\"lifecycle\": \"beta\""); + json.Should().Contain("\"lifecycle\": \"ga\""); + json.Should().Contain("\"lifecycle\": \"deprecated\""); + json.Should().Contain("\"lifecycle\": \"removed\""); + } + + [Fact] + public void SerializeComplexProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Stack = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.GenerallyAvailable, + Version = (SemVersion)"8.0.0" + } + ]), + Deployment = new DeploymentApplicability + { + Ece = AppliesCollection.GenerallyAvailable + }, + Product = AppliesCollection.GenerallyAvailable + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + // Verify it's an array with 3 items + var jsonDoc = JsonDocument.Parse(json); + jsonDoc.RootElement.GetArrayLength().Should().Be(3); + + // Verify each type is present + json.Should().Contain("\"type\": \"stack\""); + json.Should().Contain("\"type\": \"deployment\""); + json.Should().Contain("\"type\": \"product\""); + + // Verify sub_types + json.Should().Contain("\"sub_type\": \"stack\""); + json.Should().Contain("\"sub_type\": \"ece\""); + json.Should().Contain("\"sub_type\": \"product\""); + } + + [Fact] + public void SerializeEmptyProducesEmptyArray() + { + var applicableTo = new ApplicableTo(); + + var json = JsonSerializer.Serialize(applicableTo, _options); + + json.Should().Be("[]"); + } + + [Fact] + public void SerializeValidatesJsonStructure() + { + var original = new ApplicableTo + { + Stack = AppliesCollection.GenerallyAvailable, + Deployment = new DeploymentApplicability + { + Ece = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.Beta, + Version = (SemVersion)"3.0.0" + } + ]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.ValueKind.Should().Be(JsonValueKind.Array); + var array = root.EnumerateArray().ToList(); + + array.Should().HaveCount(2); // Stack + Deployment.Ece + + var stackEntry = array[0]; + stackEntry.GetProperty("type").GetString().Should().Be("stack"); + stackEntry.GetProperty("sub_type").GetString().Should().Be("stack"); + stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); + stackEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999"); + + var deploymentEntry = array[1]; + deploymentEntry.GetProperty("type").GetString().Should().Be("deployment"); + deploymentEntry.GetProperty("sub_type").GetString().Should().Be("ece"); + deploymentEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); + deploymentEntry.GetProperty("version").GetString().Should().Be("3.0.0"); + } +} diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs new file mode 100644 index 000000000..61dc00e80 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs @@ -0,0 +1,112 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Reflection; +using Elastic.Documentation; +using Elastic.Documentation.AppliesTo; +using FluentAssertions; +using YamlDotNet.Serialization; + +namespace Elastic.Markdown.Tests.AppliesTo; + +public class ProductApplicabilityToStringTests +{ + [Fact] + public void ProductApplicabilityToStringIncludesAllProperties() + { + // Create a ProductApplicability with all properties set + var productApplicability = new ProductApplicability(); + var productType = typeof(ProductApplicability); + var properties = productType.GetProperties() + .Where(p => p.GetCustomAttribute() != null) + .ToList(); + + // Set all properties to a test value + var testValue = AppliesCollection.GenerallyAvailable; + foreach (var property in properties) + { + property.SetValue(productApplicability, testValue); + } + + // Get the ToString output + var result = productApplicability.ToString(); + + // Verify that each property's YAML alias appears in the output + foreach (var property in properties) + { + var yamlAlias = property.GetCustomAttribute()!.Alias; + result.Should().Contain($"{yamlAlias}=", + $"ToString should include the property {property.Name} with alias '{yamlAlias}'"); + } + + // Verify we have the expected number of properties + properties.Should().HaveCount(22, "ProductApplicability should have exactly 22 product properties"); + } + + [Fact] + public void ProductApplicabilityToStringWithSomePropertiesOnlyIncludesSetProperties() + { + var productApplicability = new ProductApplicability + { + ApmAgentDotnet = AppliesCollection.GenerallyAvailable, + Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = new SemVersion(1, 0, 0) }]) + }; + + var result = productApplicability.ToString(); + + // Should include set properties + result.Should().Contain("apm-agent-dotnet="); + result.Should().Contain("ecctl="); + + // Should not include unset properties + result.Should().NotContain("apm-agent-node="); + result.Should().NotContain("curator="); + } + + [Fact] + public void ProductApplicabilityToStringEmptyReturnsEmptyString() + { + var productApplicability = new ProductApplicability(); + + var result = productApplicability.ToString(); + + result.Should().Be(""); + } + + [Fact] + public void ProductApplicabilityToStringPropertyOrderMatchesReflectionOrder() + { + // This test ensures that properties appear in the order they are defined + var productApplicability = new ProductApplicability + { + Ecctl = AppliesCollection.GenerallyAvailable, + Curator = AppliesCollection.GenerallyAvailable, + ApmAgentAndroid = AppliesCollection.GenerallyAvailable + }; + + var result = productApplicability.ToString(); + + // Get the properties in reflection order + var productType = typeof(ProductApplicability); + var properties = productType.GetProperties() + .Where(p => p.GetCustomAttribute() != null) + .Select(p => p.GetCustomAttribute()!.Alias) + .ToList(); + + // Find positions in the string + var positions = new Dictionary(); + foreach (var alias in new[] { "ecctl", "curator", "apm-agent-android" }) + { + var index = result.IndexOf($"{alias}=", StringComparison.Ordinal); + if (index >= 0) + positions[alias] = index; + } + + // Verify that the properties appear in the correct order + positions["ecctl"].Should().BeLessThan(positions["curator"], + "ecctl should appear before curator"); + positions["curator"].Should().BeLessThan(positions["apm-agent-android"], + "curator should appear before apm-agent-android"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs new file mode 100644 index 000000000..b3258a994 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs @@ -0,0 +1,351 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Globalization; +using System.Text.Json; +using Elastic.Documentation; +using Elastic.Documentation.AppliesTo; +using Elastic.Documentation.Search; +using Elastic.Documentation.Serialization; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Search; + +public class DocumentationDocumentSerializationTests +{ + private readonly JsonSerializerOptions _options = new(SourceGenerationContext.Default.Options); + + [Fact] + public void SerializeDocumentWithStackAppliesToProducesCorrectJson() + { + var doc = new DocumentationDocument + { + Url = "/test/page", + Title = "Test Page", + Applies = new ApplicableTo + { + Stack = AppliesCollection.GenerallyAvailable + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + // Verify applies_to existing + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + appliesTo.ValueKind.Should().Be(JsonValueKind.Array); + + // Verify structure + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(1); + + var stackEntry = appliesArray[0]; + stackEntry.GetProperty("type").GetString().Should().Be("stack"); + stackEntry.GetProperty("sub_type").GetString().Should().Be("stack"); + stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); + stackEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999"); + } + + [Fact] + public void SerializeDocumentWithDeploymentAppliesToProducesCorrectJson() + { + var doc = new DocumentationDocument + { + Url = "/test/deployment", + Title = "Deployment Test", + Applies = new ApplicableTo + { + Deployment = new DeploymentApplicability + { + Ess = AppliesCollection.GenerallyAvailable, + Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"3.5.0" }]) + } + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(2); + + // Verify ESS entry + var essEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "ess"); + essEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); + essEntry.GetProperty("type").GetString().Should().Be("deployment"); + essEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); + essEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999"); + + // Verify ECE entry + var eceEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "ece"); + eceEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); + eceEntry.GetProperty("type").GetString().Should().Be("deployment"); + eceEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); + eceEntry.GetProperty("version").GetString().Should().Be("3.5.0"); + } + + [Fact] + public void SerializeDocumentWithServerlessAppliesToProducesCorrectJson() + { + var doc = new DocumentationDocument + { + Url = "/test/serverless", + Title = "Serverless Test", + Applies = new ApplicableTo + { + Serverless = new ServerlessProjectApplicability + { + Elasticsearch = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }]), + Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"1.0.0" }]) + } + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(2); + + // Verify elasticsearch entry + var esEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "elasticsearch"); + esEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); + esEntry.GetProperty("type").GetString().Should().Be("serverless"); + esEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); + esEntry.GetProperty("version").GetString().Should().Be("8.0.0"); + + // Verify security entry + var secEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "security"); + secEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); + secEntry.GetProperty("type").GetString().Should().Be("serverless"); + secEntry.GetProperty("lifecycle").GetString().Should().Be("preview"); + secEntry.GetProperty("version").GetString().Should().Be("1.0.0"); + } + + [Fact] + public void SerializeDocumentWithProductAppliesToProducesCorrectJson() + { + var doc = new DocumentationDocument + { + Url = "/test/product", + Title = "Product Test", + Applies = new ApplicableTo + { + Product = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"2.0.0" }]) + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(1); + + var productEntry = appliesArray[0]; + productEntry.GetProperty("type").GetString().Should().Be("product"); + productEntry.GetProperty("sub_type").GetString().Should().Be("product"); + productEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); + productEntry.GetProperty("version").GetString().Should().Be("2.0.0"); + } + + [Fact] + public void SerializeDocumentWithProductApplicabilityProducesCorrectJson() + { + var doc = new DocumentationDocument + { + Url = "/test/apm", + Title = "APM Test", + Applies = new ApplicableTo + { + ProductApplicability = new ProductApplicability + { + ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.5.0" }]), + ApmAgentNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"2.0.0" }]) + } + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(2); + + // Verify apm-agent-dotnet entry + var dotnetEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "apm-agent-dotnet"); + dotnetEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); + dotnetEntry.GetProperty("type").GetString().Should().Be("product"); + dotnetEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); + dotnetEntry.GetProperty("version").GetString().Should().Be("1.5.0"); + + // Verify apm-agent-node entry + var nodeEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "apm-agent-node"); + nodeEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); + nodeEntry.GetProperty("type").GetString().Should().Be("product"); + nodeEntry.GetProperty("lifecycle").GetString().Should().Be("deprecated"); + nodeEntry.GetProperty("version").GetString().Should().Be("2.0.0"); + } + + [Fact] + public void SerializeDocumentWithComplexAppliesToProducesCorrectJson() + { + var doc = new DocumentationDocument + { + Url = "/test/complex", + Title = "Complex Test", + Applies = new ApplicableTo + { + Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }]), + Deployment = new DeploymentApplicability + { + Ess = AppliesCollection.GenerallyAvailable + }, + Serverless = new ServerlessProjectApplicability + { + Elasticsearch = AppliesCollection.GenerallyAvailable + } + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(3); + + // Verify we have all three types + appliesArray.Should().Contain(e => e.GetProperty("type").GetString() == "stack"); + appliesArray.Should().Contain(e => e.GetProperty("type").GetString() == "deployment"); + appliesArray.Should().Contain(e => e.GetProperty("type").GetString() == "serverless"); + } + + [Fact] + public void SerializeDocumentWithNullAppliesToOmitsField() + { + var doc = new DocumentationDocument + { + Url = "/test/no-applies", + Title = "No Applies Test", + Applies = null + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + // With default JSON options, null values might be omitted or serialized as null + // Let's check both possibilities + if (root.TryGetProperty("applies_to", out var appliesTo)) + appliesTo.ValueKind.Should().Be(JsonValueKind.Null); + else + { + // Field is omitted, which is also acceptable + true.Should().BeTrue(); + } + } + + [Fact] + public void SerializeDocumentWithEmptyAppliesToProducesEmptyArray() + { + var doc = new DocumentationDocument + { + Url = "/test/empty-applies", + Title = "Empty Applies Test", + Applies = new ApplicableTo() + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + appliesTo.ValueKind.Should().Be(JsonValueKind.Array); + appliesTo.GetArrayLength().Should().Be(0); + } + + [Fact] + public void RoundTripDocumentWithAppliesToPreservesData() + { + var original = new DocumentationDocument + { + Url = "/test/roundtrip", + Title = "Round Trip Test", + Hash = "abc123", + BatchIndexDate = DateTimeOffset.Parse("2024-01-15T10:00:00Z", CultureInfo.InvariantCulture), + LastUpdated = DateTimeOffset.Parse("2024-01-15T09:00:00Z", CultureInfo.InvariantCulture), + Applies = new ApplicableTo + { + Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.5.0" }]), + Deployment = new DeploymentApplicability + { + Ess = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"8.6.0" }]) + } + }, + Headings = ["Introduction", "Getting Started"], + Links = ["/link1", "/link2"], + Body = "Test body content", + StrippedBody = "Test body content", + Description = "Test description" + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized.Url.Should().Be(original.Url); + deserialized.Title.Should().Be(original.Title); + deserialized.Applies.Should().NotBeNull(); + deserialized.Applies!.Stack.Should().BeEquivalentTo(original.Applies!.Stack); + deserialized.Applies.Deployment.Should().NotBeNull(); + deserialized.Applies.Deployment!.Ess.Should().BeEquivalentTo(original.Applies.Deployment!.Ess); + } + + [Fact] + public void SerializeDocumentWithMultipleApplicabilitiesPerTypeProducesMultipleArrayEntries() + { + var doc = new DocumentationDocument + { + Url = "/test/multiple", + Title = "Multiple Test", + Applies = new ApplicableTo + { + Stack = new AppliesCollection( + [ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" }, + new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"7.0.0" } + ]) + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(3); + + // All should be stack type + appliesArray.Should().OnlyContain(e => e.GetProperty("type").GetString() == "stack"); + appliesArray.Should().OnlyContain(e => e.GetProperty("sub_type").GetString() == "stack"); + + // Verify different lifecycle values + var lifecycles = appliesArray.Select(e => e.GetProperty("lifecycle").GetString()).ToList(); + lifecycles.Should().Contain("ga"); + lifecycles.Should().Contain("beta"); + lifecycles.Should().Contain("deprecated"); + } +}