diff --git a/tests/Elastic.Markdown.Tests/CrossLinks/LinksJsonForwardCompatibilityTests.cs b/tests/Elastic.Markdown.Tests/CrossLinks/LinksJsonForwardCompatibilityTests.cs new file mode 100644 index 0000000000..8099aedfb4 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/CrossLinks/LinksJsonForwardCompatibilityTests.cs @@ -0,0 +1,129 @@ +// 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.Collections.Frozen; +using AwesomeAssertions; +using Elastic.Documentation.Links; +using Elastic.Documentation.Links.CrossLinks; + +namespace Elastic.Markdown.Tests.CrossLinks; + +/// +/// links.json is published by one build and read by every other build, including builds of older versions of +/// docs-builder. A newer build can add properties, such as the pages of an API. An older reader must ignore what it +/// does not know and read everything else exactly as before. +/// +public class LinksJsonForwardCompatibilityTests +{ + private const string Core = + // language=json + """ + "origin": { "branch": "main", "remote": "https://github.com/elastic/docs-content", "ref": "76aac68d066e2af935c38bca8ce04d3ee67a8dd9" }, + "url_path_prefix": "/elastic/docs-content/tree/main", + "cross_links": [ "kibana://index.md" ], + "links": { + "index.md": {}, + "get-started/index.md": { "anchors": [ "intro", "use-cases" ] }, + "hidden-page.md": { "hidden": true } + }, + "redirects": { "old.md": { "to": "get-started/index.md" } } + """; + + private static string WithProperty(string property) => "{" + Core + "," + property + "}"; + + /// The section a later build writes for an API: each version states its root, and each page names the versions that contain it. + private const string FloatingSection = + // language=json + """ + "floating": { + "api/elasticsearch": { + "versions": [ + { "name": "8", "root": "api/doc/elasticsearch/v8", "hidden": true }, + { "name": "main", "root": "api/doc/elasticsearch", "current": true } + ], + "pages": { "": 3, "operation/operation-search": 3, "operation/operation-new": 2 }, + "aliases": { "8": { "operation/operation-search": [ "operation/operation-search-1" ] } } + } + } + """; + + public static IEnumerable FutureProperties() => + [ + FloatingSection, + // language=json + "\"floating\": \"a string\"", + // language=json + "\"floating\": 42", + // language=json + "\"floating\": [ 1, 2, { \"nested\": [ true, null ] } ]", + // language=json + "\"floating\": { \"api/elasticsearch\": { \"versions\": [ { \"name\": \"main\", \"unheard_of\": [ 1 ] } ], \"something_new\": {} } }", + // language=json + "\"some_other_future_property\": { \"a\": 1 }" + ]; + + private static void AssertReadsTheDocumentedProperties(RepositoryLinks links) + { + links.Origin.Branch.Should().Be("main"); + links.UrlPathPrefix.Should().Be("/elastic/docs-content/tree/main"); + links.CrossLinks.Should().Equal("kibana://index.md"); + links.Links.Keys.Should().BeEquivalentTo("index.md", "get-started/index.md", "hidden-page.md"); + links.Links["get-started/index.md"].Anchors.Should().Equal("intro", "use-cases"); + links.Links["hidden-page.md"].Hidden.Should().BeTrue(); + links.Redirects.Should().ContainKey("old.md").WhoseValue.To.Should().Be("get-started/index.md"); + } + + [Test] + [MethodDataSource(nameof(FutureProperties))] + public void Deserialize_WithAPropertyAnOlderReaderDoesNotKnow_ReadsTheRestUnchanged(string property) => + AssertReadsTheDocumentedProperties(RepositoryLinks.Deserialize(WithProperty(property))); + + [Test] + public void Deserialize_WithAndWithoutAFloatingSection_GivesTheSameLinks() + { + var without = RepositoryLinks.Deserialize("{" + Core + "}"); + var with = RepositoryLinks.Deserialize(WithProperty(FloatingSection)); + + with.Links.Should().BeEquivalentTo(without.Links); + with.Redirects.Should().BeEquivalentTo(without.Redirects); + with.CrossLinks.Should().Equal(without.CrossLinks); + with.Origin.Should().Be(without.Origin); + } + + [Test] + public void CrossLinkFetcher_Deserialize_WithAFloatingSection_ReadsTheRest() => + AssertReadsTheDocumentedProperties(CrossLinkFetcher.Deserialize(WithProperty(FloatingSection))); + + [Test] + [Arguments("docs-content://index.md", true)] + [Arguments("docs-content://get-started/index.md#intro", true)] + [Arguments("docs-content://get-started/index.md#missing-anchor", false)] + [Arguments("docs-content://does-not-exist.md", false)] + public void CrossLinks_WithAFloatingSectionInTheTargetLinks_ResolveAsBefore(string crossLink, bool expected) + { + var resolve = (RepositoryLinks links) => + { + var fetched = new FetchedCrossLinks + { + DeclaredRepositories = ["docs-content"], + LinkReferences = new Dictionary { ["docs-content"] = links }.ToFrozenDictionary(), + LinkIndexEntries = new Dictionary().ToFrozenDictionary() + }; + var resolved = CrossLinkResolver.TryResolve( + _ => { }, + fetched, + new IsolatedBuildEnvironmentUriResolver(), + new Uri(crossLink), + out var uri + ); + return (resolved, uri?.ToString()); + }; + + var without = resolve(RepositoryLinks.Deserialize("{" + Core + "}")); + var with = resolve(RepositoryLinks.Deserialize(WithProperty(FloatingSection))); + + with.resolved.Should().Be(expected); + with.Should().Be(without, "a floating section changes nothing for pages that are not part of it"); + } +}