From f87df8413804f7de2302186e5ae559203cf1727c Mon Sep 17 00:00:00 2001 From: Jason Johnston Date: Fri, 10 Jul 2026 13:00:15 -0400 Subject: [PATCH 1/2] Ran copilot /init --- .github/copilot-instructions.md | 76 +++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..2511250 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,76 @@ +# Copilot instructions for msgraph-cloud-support + +## What this tool does + +`CheckCloudSupport` is a .NET console tool that determines which Microsoft national +clouds (Global, US Government L4/L5, China) each Microsoft Graph API supports, then +injects a `[!INCLUDE [national-cloud-support]...]` line into the API's Markdown +reference doc. It does this by cross-referencing two external inputs: + +1. **OpenAPI descriptions** (`Prod.yml` = Global, `Fairfax.yml` = US Gov, + `Mooncake.yml` = China) from the `msgraph-metadata` repo. +2. **API reference Markdown docs** from the `microsoft-graph-docs` (or + `m365copilot-docs-pr`) repo. + +Neither of those repos lives here — paths are passed as CLI arguments. See +`.vscode/launch.json` for concrete example invocations. + +## Build / test / run + +- **Target framework:** `net10.0` (both projects). CI (`.github/workflows/dotnet.yml`) uses the `10.x` SDK. +- **Build:** `dotnet build` +- **Test (full suite):** `dotnet test` +- **Run a single test:** `dotnet test --filter "FullyQualifiedName~ApiDocumentTests.CreateFromMarkdownFile_LoadsGraphApiFileCorrectly"` (or `--filter "DisplayName~"`). +- **Run the tool:** `dotnet run --project src -- --open-api --api-docs [--overrides ] [--excludes ]`. + Use the `copilot` subcommand for the M365 Copilot docs, which expects the OpenAPI + folder to contain `v1.0/` and `beta/` subfolders and requires `--include-directory`. + +## Architecture / data flow + +The whole pipeline is orchestrated in `src/Program.cs` (top-level statements, +System.CommandLine). There are two commands: the root command (single API version) +and `copilot` (processes v1.0 and beta separately, emitting zone-pivoted INCLUDEs when +the two versions differ). + +Flow: `DocSet.CreateFromDirectory` recursively loads every `*.md`, building an +`ApiDocument` per file (files whose `doc_type` is not `apiPageType` are skipped). Each +`ApiDocument` parses its "HTTP request" section with Markdig into `ApiOperation`s. +Separately, each cloud's OpenAPI YAML is attached to an `OpenApiUrlTreeNode`. For every +operation, `OpenApiUrlTreeNodeExtensions.GetNodeByPath` locates the matching tree node +and `GetCloudSupportStatus` checks which clouds expose that method, producing a +`CloudSupportStatus`. Finally `ApiDocument.AddOrUpdateIncludeLine` (or +`AddOrUpdatePivotedIncludeLine`) rewrites the Markdown file. + +Key types: +- `CloudSupportStatus` — a `[Flags]` enum. Individual clouds (`Global`, `China`, + `USGovL4`, `USGovL5`) are OR-combined; named combinations (`AllClouds`, + `GlobalAndChina`, etc.) map 1:1 to specific INCLUDE files (`all-clouds.md`, + `global-china.md`, ...). When adding a new cloud combination you must add both an enum + member and a matching `switch` arm in `ApiDocument.GetIncludeLine`. +- `OpenAPIOverrides` — static, initialized once from two optional JSON files. + `overrides.json` remaps an API path (docs path -> OpenAPI path) when the two don't + match; `cloud-exclusions.json` force-excludes a specific API+method+cloud, or an + entire file+cloud. JSON schemas are in `schema/`. `src/overrides.json` and + `src/cloud-exclusions.json` are sample/seed files. + +## Conventions + +- **Doc/OpenAPI path mismatches are handled by string normalization, not by editing + data.** `ApiOperation.CreateFromStringLine` runs the raw path through a chain of + `StringExtensions` methods (`MakePathRelativeToVersion`, `NormalizeIdSegments`, + `FixUserDrivePath`, `FixWellKnownMailFoldersId`, etc.). Add new quirks as another + chained extension + `[GeneratedRegex]` method rather than special-casing callers. +- **Path segment matching is case-insensitive and namespace-aware.** Use the + `IsEqualIgnoringCase` extension (it also has special handling for OData function + parameter formatting) rather than raw `==`/`string.Compare`. +- **StyleCop is enforced** via `Stylecop.Analyzers` with `stylecop.json`. Every source + file starts with the `// Copyright (c) Microsoft Corporation.` / `// Licensed under + the MIT license.` header, `using` directives go **outside** the namespace, and all + public members need XML doc comments (`GenerateDocumentationFile` is on). `SA1101` + (prefix local calls with `this.`) is silenced in `.editorconfig`. +- **Indentation:** 4 spaces for C#, 2 spaces otherwise (`.editorconfig`). +- **Logging:** never `Console.WriteLine` for diagnostics — use + `OutputLogger.Logger?.Log...` with structured message templates. +- **Tests** are xUnit; the src project exposes internals via `InternalsVisibleTo`. + Path-related tests branch on OS (`RuntimeInformation.IsOSPlatform`) because Windows + and Unix produce different relative paths, so keep both `TheoryData` sets in sync. From 345f147d7d2ce9df04bae8e5395fc1bc6f70405f Mon Sep 17 00:00:00 2001 From: Jason Johnston Date: Fri, 10 Jul 2026 13:58:12 -0400 Subject: [PATCH 2/2] Expand test coverage and extract cloud support decision logic Add unit tests for the cloud-matching engine (GetNodeByPath, GetCloudSupportStatus), OpenAPIOverrides, CloudSupportStatus to INCLUDE file mapping and include writers, ApiOperation parsing/error paths, the remaining StringExtensions methods, and DocSet directory loading / doc_type skipping. Extract the duplicated status-merge and version-pivot decision logic from Program.cs into a testable CloudSupportStatusEvaluator, and cover it with unit tests. Test suite grows from 12 to 90 tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3fba873e-7a3c-46e6-8884-edf08f26692a --- src/Docs/CloudSupportStatusEvaluator.cs | 54 ++++++ src/Program.cs | 44 ++--- test/ApiDocumentIncludeTests.cs | 116 ++++++++++++ test/ApiOperationTests.cs | 47 +++++ test/CloudSupportStatusEvaluatorTests.cs | 93 ++++++++++ test/DocSetTests.cs | 48 +++++ test/OpenAPIOverridesTests.cs | 87 +++++++++ test/OpenApiUrlTreeNodeExtensionsTests.cs | 180 +++++++++++++++++++ test/StringExtensionsTests.cs | 98 ++++++++++ test/test-data/docset/nested/valid-nested.md | 21 +++ test/test-data/docset/skip-conceptual.md | 10 ++ test/test-data/docset/skip-no-doctype.md | 3 + test/test-data/docset/valid1.md | 21 +++ test/test-data/openapi-china.yml | 19 ++ test/test-data/openapi-global.yml | 31 ++++ test/test-data/openapi-usgov.yml | 13 ++ test/test-data/test-exclusions.json | 11 ++ test/test-data/test-l5-exclusion.json | 7 + test/test-data/test-overrides.json | 11 ++ 19 files changed, 886 insertions(+), 28 deletions(-) create mode 100644 src/Docs/CloudSupportStatusEvaluator.cs create mode 100644 test/ApiDocumentIncludeTests.cs create mode 100644 test/ApiOperationTests.cs create mode 100644 test/CloudSupportStatusEvaluatorTests.cs create mode 100644 test/DocSetTests.cs create mode 100644 test/OpenAPIOverridesTests.cs create mode 100644 test/OpenApiUrlTreeNodeExtensionsTests.cs create mode 100644 test/test-data/docset/nested/valid-nested.md create mode 100644 test/test-data/docset/skip-conceptual.md create mode 100644 test/test-data/docset/skip-no-doctype.md create mode 100644 test/test-data/docset/valid1.md create mode 100644 test/test-data/openapi-china.yml create mode 100644 test/test-data/openapi-global.yml create mode 100644 test/test-data/openapi-usgov.yml create mode 100644 test/test-data/test-exclusions.json create mode 100644 test/test-data/test-l5-exclusion.json create mode 100644 test/test-data/test-overrides.json diff --git a/src/Docs/CloudSupportStatusEvaluator.cs b/src/Docs/CloudSupportStatusEvaluator.cs new file mode 100644 index 0000000..079915b --- /dev/null +++ b/src/Docs/CloudSupportStatusEvaluator.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace CheckCloudSupport.Docs; + +/// +/// Contains the decision logic for combining and interpreting +/// values across an API document's operations. +/// +public static class CloudSupportStatusEvaluator +{ + /// + /// Merges an operation's cloud support status into an accumulated status. + /// + /// The status accumulated so far. + /// The status of the current operation. + /// + /// Set to when both statuses are known but disagree, + /// in which case they are combined with a bitwise OR. This allows callers to + /// surface a warning about the disagreement. + /// + /// The merged . + public static CloudSupportStatus Merge( + CloudSupportStatus accumulated, + CloudSupportStatus operationStatus, + out bool mismatch) + { + mismatch = operationStatus != CloudSupportStatus.Unknown && + accumulated != CloudSupportStatus.Unknown && + operationStatus != accumulated; + + if (mismatch) + { + return accumulated | operationStatus; + } + + return operationStatus != CloudSupportStatus.Unknown ? operationStatus : accumulated; + } + + /// + /// Determines whether an API document requires version-pivoted include lines. + /// This is the case when the v1 and beta statuses differ and the v1 status is known. + /// + /// The cloud support status for the v1 pivot. + /// The cloud support status for the beta pivot. + /// + /// if separate v1 and beta include lines are required; + /// otherwise, . + /// + public static bool RequiresVersionPivot(CloudSupportStatus v1Status, CloudSupportStatus betaStatus) + { + return v1Status != betaStatus && v1Status != CloudSupportStatus.Unknown; + } +} diff --git a/src/Program.cs b/src/Program.cs index b4c8419..1a0e0bb 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -150,22 +150,18 @@ var supportStatus = operationNode.GetCloudSupportStatus(operation.Method, apiDoc.FilePath); OutputLogger.Logger?.LogInformation("{path} support status: {status}", operation.Path, supportStatus); - if (supportStatus != CloudSupportStatus.Unknown && - apiDoc.CloudSupportStatus != CloudSupportStatus.Unknown && - supportStatus != apiDoc.CloudSupportStatus) + + var mergedStatus = CloudSupportStatusEvaluator.Merge(apiDoc.CloudSupportStatus, supportStatus, out var statusMismatch); + if (statusMismatch) { OutputLogger.Logger?.LogWarning( "Mismatched support status in API doc {path}: {newStatus}, {oldStatus}", apiDoc.FilePath, supportStatus, apiDoc.CloudSupportStatus); - - apiDoc.CloudSupportStatus |= supportStatus; - } - else - { - apiDoc.CloudSupportStatus = supportStatus != CloudSupportStatus.Unknown ? supportStatus : apiDoc.CloudSupportStatus; } + + apiDoc.CloudSupportStatus = mergedStatus; } try @@ -329,9 +325,9 @@ var supportStatus = operationNode.GetCloudSupportStatus(operation.Method, apiDoc.FilePath); OutputLogger.Logger?.LogInformation("{version} {path} support status: {status}", "v1", operation.Path, supportStatus); - if (supportStatus != CloudSupportStatus.Unknown && - v1CloudSupportStatus != CloudSupportStatus.Unknown && - supportStatus != v1CloudSupportStatus) + + var mergedStatus = CloudSupportStatusEvaluator.Merge(v1CloudSupportStatus, supportStatus, out var statusMismatch); + if (statusMismatch) { OutputLogger.Logger?.LogWarning( "Mismatched {version} support status in API doc {path}: {newStatus}, {oldStatus}", @@ -339,13 +335,9 @@ apiDoc.FilePath, supportStatus, v1CloudSupportStatus); - - v1CloudSupportStatus |= supportStatus; - } - else - { - v1CloudSupportStatus = supportStatus != CloudSupportStatus.Unknown ? supportStatus : v1CloudSupportStatus; } + + v1CloudSupportStatus = mergedStatus; } var betaCloudSupportStatus = CloudSupportStatus.Unknown; @@ -366,9 +358,9 @@ var supportStatus = operationNode.GetCloudSupportStatus(operation.Method, apiDoc.FilePath); OutputLogger.Logger?.LogInformation("{version} {path} support status: {status}", "beta", operation.Path, supportStatus); - if (supportStatus != CloudSupportStatus.Unknown && - betaCloudSupportStatus != CloudSupportStatus.Unknown && - supportStatus != betaCloudSupportStatus) + + var mergedStatus = CloudSupportStatusEvaluator.Merge(betaCloudSupportStatus, supportStatus, out var statusMismatch); + if (statusMismatch) { OutputLogger.Logger?.LogWarning( "Mismatched {version} support status in API doc {path}: {newStatus}, {oldStatus}", @@ -376,13 +368,9 @@ apiDoc.FilePath, supportStatus, betaCloudSupportStatus); - - betaCloudSupportStatus |= supportStatus; - } - else - { - betaCloudSupportStatus = supportStatus != CloudSupportStatus.Unknown ? supportStatus : betaCloudSupportStatus; } + + betaCloudSupportStatus = mergedStatus; } if (v1Operations.Count > 0 && v1CloudSupportStatus == CloudSupportStatus.Unknown) @@ -390,7 +378,7 @@ OutputLogger.Logger?.LogWarning("Could not determine v1 support status for {doc} - assuming beta status", apiDoc.FilePath); } - if (v1CloudSupportStatus == betaCloudSupportStatus || v1CloudSupportStatus == CloudSupportStatus.Unknown) + if (!CloudSupportStatusEvaluator.RequiresVersionPivot(v1CloudSupportStatus, betaCloudSupportStatus)) { apiDoc.CloudSupportStatus = betaCloudSupportStatus; diff --git a/test/ApiDocumentIncludeTests.cs b/test/ApiDocumentIncludeTests.cs new file mode 100644 index 0000000..20b0c38 --- /dev/null +++ b/test/ApiDocumentIncludeTests.cs @@ -0,0 +1,116 @@ +using CheckCloudSupport.Docs; + +namespace CheckCloudSupportTests; + +public class ApiDocumentIncludeTests +{ + private const string SourceDoc = "../../../test-data/graph-api.md"; + + private static string CopyToTemp() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"cloud-support-{Guid.NewGuid():N}.md"); + File.Copy(SourceDoc, tempPath, overwrite: true); + return tempPath; + } + + [Theory] + [InlineData(CloudSupportStatus.AllClouds, "all-clouds.md")] + [InlineData(CloudSupportStatus.GlobalAndChina, "global-china.md")] + [InlineData(CloudSupportStatus.GlobalAndChinaAndUsGovL4, "global-china-us-l4.md")] + [InlineData(CloudSupportStatus.GlobalAndChinaAndUsGovL5, "global-china-us-l5.md")] + [InlineData(CloudSupportStatus.GlobalAndUSGov, "global-us.md")] + [InlineData(CloudSupportStatus.GlobalAndUsGovL4, "global-us-l4.md")] + [InlineData(CloudSupportStatus.GlobalAndUsGovL5, "global-us-l5.md")] + [InlineData(CloudSupportStatus.Global, "global-only.md")] + public async Task AddOrUpdateIncludeLine_InsertsIncludeForStatus(CloudSupportStatus status, string expectedFile) + { + var tempPath = CopyToTemp(); + try + { + var apiDocument = await ApiDocument.CreateFromMarkdownFile(tempPath); + apiDocument.CloudSupportStatus = status; + + await apiDocument.AddOrUpdateIncludeLine(removeOldIncludes: false); + + var lines = await File.ReadAllLinesAsync(tempPath); + var includeLines = lines.Where(l => l.Contains("[!INCLUDE [national-cloud-support]")).ToList(); + Assert.Single(includeLines); + Assert.Contains($"includes/{expectedFile})]", includeLines[0]); + } + finally + { + File.Delete(tempPath); + } + } + + [Fact] + public async Task AddOrUpdateIncludeLine_ThrowsForUnmappedStatus() + { + var tempPath = CopyToTemp(); + try + { + var apiDocument = await ApiDocument.CreateFromMarkdownFile(tempPath); + apiDocument.CloudSupportStatus = CloudSupportStatus.China; + + await Assert.ThrowsAsync( + () => apiDocument.AddOrUpdateIncludeLine(removeOldIncludes: false)); + } + finally + { + File.Delete(tempPath); + } + } + + [Fact] + public async Task AddOrUpdateIncludeLine_UpdatesExistingLineInPlace() + { + var tempPath = CopyToTemp(); + try + { + var apiDocument = await ApiDocument.CreateFromMarkdownFile(tempPath); + + apiDocument.CloudSupportStatus = CloudSupportStatus.Global; + await apiDocument.AddOrUpdateIncludeLine(removeOldIncludes: false); + + apiDocument.CloudSupportStatus = CloudSupportStatus.AllClouds; + await apiDocument.AddOrUpdateIncludeLine(removeOldIncludes: false); + + var lines = await File.ReadAllLinesAsync(tempPath); + var includeLines = lines.Where(l => l.Contains("[!INCLUDE [national-cloud-support]")).ToList(); + Assert.Single(includeLines); + Assert.Contains("includes/all-clouds.md)]", includeLines[0]); + } + finally + { + File.Delete(tempPath); + } + } + + [Fact] + public async Task AddOrUpdatePivotedIncludeLine_WritesZonePivotsAndBothIncludes() + { + var tempPath = CopyToTemp(); + var includeDirectory = Path.Combine(Path.GetDirectoryName(tempPath)!, "includes"); + try + { + var apiDocument = await ApiDocument.CreateFromMarkdownFile(tempPath); + + await apiDocument.AddOrUpdatePivotedIncludeLine( + CloudSupportStatus.Global, + CloudSupportStatus.AllClouds, + includeDirectory); + + var lines = await File.ReadAllLinesAsync(tempPath); + + Assert.Contains(lines, l => l.StartsWith(":::zone pivot=\"graph-v1\"")); + Assert.Contains(lines, l => l.StartsWith(":::zone pivot=\"graph-preview\"")); + Assert.Contains(lines, l => l.Contains("[!INCLUDE [version-support-differs]")); + Assert.Contains(lines, l => l.Contains("global-only.md)]")); + Assert.Contains(lines, l => l.Contains("all-clouds.md)]")); + } + finally + { + File.Delete(tempPath); + } + } +} diff --git a/test/ApiOperationTests.cs b/test/ApiOperationTests.cs new file mode 100644 index 0000000..dab4c0d --- /dev/null +++ b/test/ApiOperationTests.cs @@ -0,0 +1,47 @@ +using CheckCloudSupport.Docs; +using Markdig.Helpers; + +namespace CheckCloudSupportTests; + +public class ApiOperationTests +{ + private static ApiOperation? CreateFrom(string text) + { + var slice = new StringSlice(text); + var line = new StringLine(ref slice); + return ApiOperation.CreateFromStringLine(line); + } + + [Fact] + public void CreateFromStringLine_ParsesMethodAndNormalizedPath() + { + var operation = CreateFrom("GET https://graph.microsoft.com/v1.0/users/{user-id}/messages/{message-id}"); + + Assert.NotNull(operation); + Assert.Equal(HttpMethod.Get, operation!.Method); + Assert.Equal("/users/{id}/messages/{id}", operation.Path); + Assert.Equal(ApiVersion.V1, operation.Version); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void CreateFromStringLine_ReturnsNullForEmptyLine(string text) + { + Assert.Null(CreateFrom(text)); + } + + [Fact] + public void CreateFromStringLine_ThrowsWhenNoPath() + { + var ex = Assert.Throws(() => CreateFrom("GET")); + Assert.Contains("Invalid line text", ex.Message); + } + + [Fact] + public void CreateFromStringLine_ThrowsForInvalidHttpMethod() + { + var ex = Assert.Throws(() => CreateFrom("N@T-A-METHOD /me/messages")); + Assert.Contains("Invalid HTTP operation", ex.Message); + } +} diff --git a/test/CloudSupportStatusEvaluatorTests.cs b/test/CloudSupportStatusEvaluatorTests.cs new file mode 100644 index 0000000..49a2fc8 --- /dev/null +++ b/test/CloudSupportStatusEvaluatorTests.cs @@ -0,0 +1,93 @@ +using CheckCloudSupport.Docs; + +namespace CheckCloudSupportTests; + +public class CloudSupportStatusEvaluatorTests +{ + [Fact] + public void Merge_SetsStatusWhenAccumulatedIsUnknown() + { + var result = CloudSupportStatusEvaluator.Merge( + CloudSupportStatus.Unknown, + CloudSupportStatus.Global, + out var mismatch); + + Assert.Equal(CloudSupportStatus.Global, result); + Assert.False(mismatch); + } + + [Fact] + public void Merge_KeepsAccumulatedWhenOperationIsUnknown() + { + var result = CloudSupportStatusEvaluator.Merge( + CloudSupportStatus.AllClouds, + CloudSupportStatus.Unknown, + out var mismatch); + + Assert.Equal(CloudSupportStatus.AllClouds, result); + Assert.False(mismatch); + } + + [Fact] + public void Merge_KeepsUnknownWhenBothUnknown() + { + var result = CloudSupportStatusEvaluator.Merge( + CloudSupportStatus.Unknown, + CloudSupportStatus.Unknown, + out var mismatch); + + Assert.Equal(CloudSupportStatus.Unknown, result); + Assert.False(mismatch); + } + + [Fact] + public void Merge_NoMismatchWhenStatusesAgree() + { + var result = CloudSupportStatusEvaluator.Merge( + CloudSupportStatus.GlobalAndChina, + CloudSupportStatus.GlobalAndChina, + out var mismatch); + + Assert.Equal(CloudSupportStatus.GlobalAndChina, result); + Assert.False(mismatch); + } + + [Fact] + public void Merge_CombinesWithOrWhenKnownStatusesDisagree() + { + var result = CloudSupportStatusEvaluator.Merge( + CloudSupportStatus.Global, + CloudSupportStatus.GlobalAndChina, + out var mismatch); + + Assert.True(mismatch); + Assert.Equal(CloudSupportStatus.Global | CloudSupportStatus.GlobalAndChina, result); + Assert.Equal(CloudSupportStatus.GlobalAndChina, result); + } + + [Fact] + public void Merge_OrCombinesDistinctClouds() + { + var result = CloudSupportStatusEvaluator.Merge( + CloudSupportStatus.Global, + CloudSupportStatus.China, + out var mismatch); + + Assert.True(mismatch); + Assert.Equal(CloudSupportStatus.GlobalAndChina, result); + } + + [Theory] + [InlineData(CloudSupportStatus.Global, CloudSupportStatus.Global, false)] + [InlineData(CloudSupportStatus.Unknown, CloudSupportStatus.AllClouds, false)] + [InlineData(CloudSupportStatus.Global, CloudSupportStatus.AllClouds, true)] + [InlineData(CloudSupportStatus.AllClouds, CloudSupportStatus.Global, true)] + [InlineData(CloudSupportStatus.GlobalAndChina, CloudSupportStatus.Unknown, true)] + public void RequiresVersionPivot_ReturnsExpected( + CloudSupportStatus v1Status, + CloudSupportStatus betaStatus, + bool expected) + { + Assert.Equal(expected, CloudSupportStatusEvaluator.RequiresVersionPivot(v1Status, betaStatus)); + } +} diff --git a/test/DocSetTests.cs b/test/DocSetTests.cs new file mode 100644 index 0000000..24a8ab6 --- /dev/null +++ b/test/DocSetTests.cs @@ -0,0 +1,48 @@ +using CheckCloudSupport.Docs; + +namespace CheckCloudSupportTests; + +public class DocSetTests +{ + private const string DocSetRoot = "../../../test-data/docset"; + + [Fact] + public async Task CreateFromDirectory_LoadsOnlyApiDocsRecursively() + { + var docSet = await DocSet.CreateFromDirectory(DocSetRoot); + + Assert.Equal(DocSetRoot, docSet.RootDirectory); + + // Two apiPageType docs (one nested); the conceptual and no-doc_type files are skipped. + Assert.Equal(2, docSet.ApiDocuments.Count); + + var fileNames = docSet.ApiDocuments.Select(d => Path.GetFileName(d.FilePath)).ToList(); + Assert.Contains("valid1.md", fileNames); + Assert.Contains("valid-nested.md", fileNames); + Assert.DoesNotContain("skip-conceptual.md", fileNames); + Assert.DoesNotContain("skip-no-doctype.md", fileNames); + } + + [Fact] + public async Task CreateFromDirectory_ParsesOperationsFromLoadedDocs() + { + var docSet = await DocSet.CreateFromDirectory(DocSetRoot); + + var nested = docSet.ApiDocuments.Single(d => Path.GetFileName(d.FilePath) == "valid-nested.md"); + Assert.Collection(nested.ApiOperations, op => Assert.Equal("/me/valid-nested", op.Path)); + } + + [Fact] + public async Task CreateFromMarkdownFile_ThrowsForNonApiDoc() + { + await Assert.ThrowsAsync( + () => ApiDocument.CreateFromMarkdownFile($"{DocSetRoot}/skip-conceptual.md")); + } + + [Fact] + public async Task CreateFromMarkdownFile_ThrowsWhenDocTypeMissing() + { + await Assert.ThrowsAsync( + () => ApiDocument.CreateFromMarkdownFile($"{DocSetRoot}/skip-no-doctype.md")); + } +} diff --git a/test/OpenAPIOverridesTests.cs b/test/OpenAPIOverridesTests.cs new file mode 100644 index 0000000..5149ba2 --- /dev/null +++ b/test/OpenAPIOverridesTests.cs @@ -0,0 +1,87 @@ +using CheckCloudSupport.OpenAPI; + +namespace CheckCloudSupportTests; + +[Collection("OpenAPIState")] +public class OpenAPIOverridesTests +{ + private const string OverridesFile = "../../../test-data/test-overrides.json"; + private const string ExclusionsFile = "../../../test-data/test-exclusions.json"; + + public OpenAPIOverridesTests() + { + // OpenAPIOverrides holds static state; re-initialize before each test. + OpenAPIOverrides.Initialize(OverridesFile, ExclusionsFile); + } + + [Fact] + public void CheckForOverride_ReturnsOverrideWhenNoOperationConstraint() + { + var result = OpenAPIOverrides.CheckForOverride("/sites/{id}/pages/microsoft.graph.sitePage", HttpMethod.Get); + Assert.Equal("/sites/{id}/pages", result); + } + + [Fact] + public void CheckForOverride_ReturnsOverrideWhenOperationMatches() + { + var result = OpenAPIOverrides.CheckForOverride("/places/{id}", HttpMethod.Get); + Assert.Equal("/places/{id}/graph.room", result); + } + + [Fact] + public void CheckForOverride_ReturnsOriginalWhenOperationDoesNotMatch() + { + var result = OpenAPIOverrides.CheckForOverride("/places/{id}", HttpMethod.Patch); + Assert.Equal("/places/{id}", result); + } + + [Fact] + public void CheckForOverride_ReturnsOriginalWhenNoMatch() + { + var result = OpenAPIOverrides.CheckForOverride("/me/messages", HttpMethod.Get); + Assert.Equal("/me/messages", result); + } + + [Fact] + public void CheckForOverride_MatchesCaseInsensitively() + { + var result = OpenAPIOverrides.CheckForOverride("/PLACES/{id}", HttpMethod.Get); + Assert.Equal("/places/{id}/graph.room", result); + } + + [Fact] + public void CheckIfCloudExcluded_TrueWhenPathMethodCloudMatch() + { + Assert.True(OpenAPIOverrides.CheckIfCloudExcluded("/appCatalogs/teamsApps", HttpMethod.Get, "China")); + } + + [Fact] + public void CheckIfCloudExcluded_NormalizesBackslashesInPath() + { + Assert.True(OpenAPIOverrides.CheckIfCloudExcluded("\\appCatalogs\\teamsApps", HttpMethod.Get, "China")); + } + + [Fact] + public void CheckIfCloudExcluded_FalseWhenCloudDiffers() + { + Assert.False(OpenAPIOverrides.CheckIfCloudExcluded("/appCatalogs/teamsApps", HttpMethod.Get, "UsGov")); + } + + [Fact] + public void CheckIfCloudExcluded_FalseWhenMethodDiffers() + { + Assert.False(OpenAPIOverrides.CheckIfCloudExcluded("/appCatalogs/teamsApps", HttpMethod.Post, "China")); + } + + [Fact] + public void CheckIfCloudExcludedForFile_TrueWhenFileAndCloudMatch() + { + Assert.True(OpenAPIOverrides.CheckIfCloudExcludedForFile("excluded-file.md", "UsGov")); + } + + [Fact] + public void CheckIfCloudExcludedForFile_FalseWhenFileNotExcluded() + { + Assert.False(OpenAPIOverrides.CheckIfCloudExcludedForFile("some-other-file.md", "UsGov")); + } +} diff --git a/test/OpenApiUrlTreeNodeExtensionsTests.cs b/test/OpenApiUrlTreeNodeExtensionsTests.cs new file mode 100644 index 0000000..c37aad0 --- /dev/null +++ b/test/OpenApiUrlTreeNodeExtensionsTests.cs @@ -0,0 +1,180 @@ +using CheckCloudSupport.Docs; +using CheckCloudSupport.Extensions; +using CheckCloudSupport.OpenAPI; +using Markdig.Helpers; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; + +namespace CheckCloudSupportTests; + +/// +/// Shared collection to serialize tests that mutate the static +/// state. +/// +[CollectionDefinition("OpenAPIState", DisableParallelization = true)] +public class OpenAPIStateCollection +{ +} + +[Collection("OpenAPIState")] +public class OpenApiUrlTreeNodeExtensionsTests +{ + private static ApiOperation BuildOperation(string httpLine) + { + var slice = new StringSlice(httpLine); + var line = new StringLine(ref slice); + var operation = ApiOperation.CreateFromStringLine(line); + Assert.NotNull(operation); + return operation!; + } + + private static async Task BuildTreeAsync() + { + var clouds = new Dictionary + { + { "Global", "../../../test-data/openapi-global.yml" }, + { "UsGov", "../../../test-data/openapi-usgov.yml" }, + { "China", "../../../test-data/openapi-china.yml" }, + }; + + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + + var tree = OpenApiUrlTreeNode.Create(); + foreach (var cloud in clouds) + { + using var stream = File.OpenRead(cloud.Value); + var loadResult = await OpenApiDocument.LoadAsync(stream, settings: settings); + Assert.NotNull(loadResult.Document); + tree.Attach(loadResult.Document!, cloud.Key); + } + + return tree; + } + + [Fact] + public async Task GetNodeByPath_FindsNodeMatchingIdSegment() + { + var tree = await BuildTreeAsync(); + var operation = BuildOperation("GET https://graph.microsoft.com/v1.0/users/{user-id}/messages"); + + var node = tree.GetNodeByPath(operation, "microsoft.graph"); + + Assert.NotNull(node); + Assert.EndsWith("messages", node!.Path.Replace('\\', '/')); + } + + [Fact] + public async Task GetNodeByPath_MatchesFunctionWithParenthesesSuffix() + { + var tree = await BuildTreeAsync(); + var operation = BuildOperation("GET https://graph.microsoft.com/v1.0/reports/getUsage"); + + var node = tree.GetNodeByPath(operation, "microsoft.graph"); + + Assert.NotNull(node); + } + + [Fact] + public async Task GetNodeByPath_ReturnsNullForUnknownPath() + { + var tree = await BuildTreeAsync(); + var operation = BuildOperation("GET https://graph.microsoft.com/v1.0/does/not/exist"); + + var node = tree.GetNodeByPath(operation, "microsoft.graph"); + + Assert.Null(node); + } + + [Fact] + public async Task GetCloudSupportStatus_ReturnsGlobalWhenOnlyGlobal() + { + OpenAPIOverrides.Initialize(null, null); + var tree = await BuildTreeAsync(); + var operation = BuildOperation("GET https://graph.microsoft.com/v1.0/security/alerts_v2/{alert-id}"); + + var node = tree.GetNodeByPath(operation, "microsoft.graph"); + Assert.NotNull(node); + + var status = node!.GetCloudSupportStatus(operation.Method, "test.md"); + + Assert.Equal(CloudSupportStatus.Global, status); + } + + [Fact] + public async Task GetCloudSupportStatus_ReturnsAllCloudsWhenInEveryCloud() + { + OpenAPIOverrides.Initialize(null, null); + var tree = await BuildTreeAsync(); + var operation = BuildOperation("GET https://graph.microsoft.com/v1.0/users/{user-id}/messages"); + + var node = tree.GetNodeByPath(operation, "microsoft.graph"); + Assert.NotNull(node); + + var status = node!.GetCloudSupportStatus(operation.Method, "test.md"); + + Assert.Equal(CloudSupportStatus.AllClouds, status); + } + + [Fact] + public async Task GetCloudSupportStatus_ReturnsGlobalAndChina() + { + OpenAPIOverrides.Initialize(null, null); + var tree = await BuildTreeAsync(); + var operation = BuildOperation("GET https://graph.microsoft.com/v1.0/deviceManagement/managedDevices"); + + var node = tree.GetNodeByPath(operation, "microsoft.graph"); + Assert.NotNull(node); + + var status = node!.GetCloudSupportStatus(operation.Method, "test.md"); + + Assert.Equal(CloudSupportStatus.GlobalAndChina, status); + } + + [Fact] + public async Task GetCloudSupportStatus_ReturnsUnknownWhenMethodNotDefined() + { + OpenAPIOverrides.Initialize(null, null); + var tree = await BuildTreeAsync(); + var operation = BuildOperation("POST https://graph.microsoft.com/v1.0/users/{user-id}/messages"); + + var node = tree.GetNodeByPath(operation, "microsoft.graph"); + Assert.NotNull(node); + + var status = node!.GetCloudSupportStatus(operation.Method, "test.md"); + + Assert.Equal(CloudSupportStatus.Unknown, status); + } + + [Fact] + public async Task GetCloudSupportStatus_ReturnsUnknownWhenMethodIsNull() + { + OpenAPIOverrides.Initialize(null, null); + var tree = await BuildTreeAsync(); + var operation = BuildOperation("GET https://graph.microsoft.com/v1.0/users/{user-id}/messages"); + + var node = tree.GetNodeByPath(operation, "microsoft.graph"); + Assert.NotNull(node); + + var status = node!.GetCloudSupportStatus(null, "test.md"); + + Assert.Equal(CloudSupportStatus.Unknown, status); + } + + [Fact] + public async Task GetCloudSupportStatus_StripsUsGovL5WhenExcluded() + { + OpenAPIOverrides.Initialize(null, "../../../test-data/test-l5-exclusion.json"); + var tree = await BuildTreeAsync(); + var operation = BuildOperation("GET https://graph.microsoft.com/v1.0/users/{user-id}/messages"); + + var node = tree.GetNodeByPath(operation, "microsoft.graph"); + Assert.NotNull(node); + + var status = node!.GetCloudSupportStatus(operation.Method, "test.md"); + + // All clouds minus US Gov L5. + Assert.Equal(CloudSupportStatus.GlobalAndChinaAndUsGovL4, status); + OpenAPIOverrides.Initialize(null, null); + } +} diff --git a/test/StringExtensionsTests.cs b/test/StringExtensionsTests.cs index e509a96..1af3485 100644 --- a/test/StringExtensionsTests.cs +++ b/test/StringExtensionsTests.cs @@ -1,3 +1,4 @@ +using CheckCloudSupport.Docs; using CheckCloudSupport.Extensions; namespace CheckCloudSupportTests; @@ -110,4 +111,101 @@ public void NamespaceIsExtractedFromMarkdown() // Assert Assert.Equal("microsoft.graph.callRecords", extractedNamespace); } + + [Theory] + [InlineData("https://graph.microsoft.com/v1.0/me/messages", "/me/messages")] + [InlineData("https://graph.microsoft.com/beta/me/messages", "/me/messages")] + [InlineData("/v1.0/users/{id}", "/users/{id}")] + [InlineData("/beta/users/{id}", "/users/{id}")] + [InlineData("/me/messages", "/me/messages")] + public void MakePathRelativeToVersion_StripsHostAndVersion(string path, string expected) + { + Assert.Equal(expected, path.MakePathRelativeToVersion()); + } + + [Theory] + [InlineData("/users/{user-id}/messages/{message-id}", "/users/{id}/messages/{id}")] + [InlineData("/workbook/charts/{name}", "/workbook/charts/{id}")] + [InlineData("/drive/root:/{item-path}:/children", "/drive/items/{id}/children")] + [InlineData("/users/{USER-ID}", "/users/{id}")] + public void NormalizeIdSegments_NormalizesToId(string path, string expected) + { + Assert.Equal(expected, path.NormalizeIdSegments()); + } + + [Theory] + [InlineData("/users/{id}/drive/items/{id}", "/drives/{id}/items/{id}")] + [InlineData("/me/drive/items/{id}", "/drives/{id}/items/{id}")] + [InlineData("/sites/{id}/drive/items/{id}", "/sites/{id}/drive/items/{id}")] + public void FixUserDrivePath_RewritesUserAndMeDriveShortcuts(string path, string expected) + { + Assert.Equal(expected, path.FixUserDrivePath()); + } + + [Fact] + public void FixDriveShareId_NormalizesEncodedSharingUrl() + { + Assert.Equal("/shares/{id}/driveItem", "/shares/{encoded-sharing-url}/driveItem".FixDriveShareId()); + } + + [Theory] + [InlineData("/me/mailFolders/inbox/messages", "/me/mailFolders/{id}/messages")] + [InlineData("/users/{id}/mailfolders/archive/messages", "/users/{id}/mailFolders/{id}/messages")] + public void FixWellKnownMailFoldersId_NormalizesFolderName(string path, string expected) + { + Assert.Equal(expected, path.FixWellKnownMailFoldersId()); + } + + [Fact] + public void ExtractDocType_ReturnsDocType() + { + Assert.Equal("apiPageType", markdownWithNamespace.ExtractDocType()); + } + + [Fact] + public void ExtractDocType_ReturnsNullWhenAbsent() + { + Assert.Null("# Just a heading\n\nSome text.".ExtractDocType()); + } + + [Fact] + public void AreZonePivotsEnabled_TrueWhenGroupDeclared() + { + var markdown = "---\nzone_pivot_groups: graph-api-versions\n---\n"; + Assert.True(markdown.AreZonePivotsEnabled()); + } + + [Fact] + public void AreZonePivotsEnabled_FalseWhenAbsent() + { + Assert.False(markdownWithNamespace.AreZonePivotsEnabled()); + } + + [Theory] + [InlineData("https://graph.microsoft.com/v1.0/me", ApiVersion.V1)] + [InlineData("https://graph.microsoft.com/beta/me", ApiVersion.Beta)] + [InlineData("https://graph.microsoft.com/V1.0/me", ApiVersion.V1)] + [InlineData("/me/messages", ApiVersion.Unknown)] + public void ExtractApiVersion_ReturnsExpectedVersion(string path, ApiVersion expected) + { + Assert.Equal(expected, path.ExtractApiVersion()); + } + + [Theory] + [InlineData("users", "USERS", true)] + [InlineData("users", "users", true)] + [InlineData("users", "messages", false)] + public void IsEqualIgnoringCase_ComparesCaseInsensitively(string value, string compareTo, bool expected) + { + Assert.Equal(expected, value.IsEqualIgnoringCase(compareTo)); + } + + [Fact] + public void IsEqualIgnoringCase_MatchesFunctionParametersWithInconsistentQuoting() + { + var value = "getPstnCalls(fromDateTime={fromDateTime})"; + var compareTo = "getPstnCalls(fromDateTime='{fromDateTime}')"; + + Assert.True(value.IsEqualIgnoringCase(compareTo)); + } } diff --git a/test/test-data/docset/nested/valid-nested.md b/test/test-data/docset/nested/valid-nested.md new file mode 100644 index 0000000..5da05e0 --- /dev/null +++ b/test/test-data/docset/nested/valid-nested.md @@ -0,0 +1,21 @@ +--- +title: "Valid nested" +doc_type: apiPageType +--- + +# Valid nested + +Namespace: microsoft.graph + +Intro text. + +## HTTP request + + +```http +GET /me/valid-nested +``` + +## Response + +If successful, returns `200 OK`. diff --git a/test/test-data/docset/skip-conceptual.md b/test/test-data/docset/skip-conceptual.md new file mode 100644 index 0000000..d31f44a --- /dev/null +++ b/test/test-data/docset/skip-conceptual.md @@ -0,0 +1,10 @@ +--- +title: "A concept" +doc_type: conceptualPageType +--- + +# A concept + +Namespace: microsoft.graph + +This is a conceptual doc, not an API reference, so it should be skipped. diff --git a/test/test-data/docset/skip-no-doctype.md b/test/test-data/docset/skip-no-doctype.md new file mode 100644 index 0000000..6793093 --- /dev/null +++ b/test/test-data/docset/skip-no-doctype.md @@ -0,0 +1,3 @@ +# No front matter + +This file has no YAML front matter and therefore no doc_type, so it should be skipped. diff --git a/test/test-data/docset/valid1.md b/test/test-data/docset/valid1.md new file mode 100644 index 0000000..846dc77 --- /dev/null +++ b/test/test-data/docset/valid1.md @@ -0,0 +1,21 @@ +--- +title: "Valid one" +doc_type: apiPageType +--- + +# Valid one + +Namespace: microsoft.graph + +Intro text. + +## HTTP request + + +```http +GET /me/valid1 +``` + +## Response + +If successful, returns `200 OK`. diff --git a/test/test-data/openapi-china.yml b/test/test-data/openapi-china.yml new file mode 100644 index 0000000..a63c2cb --- /dev/null +++ b/test/test-data/openapi-china.yml @@ -0,0 +1,19 @@ +openapi: 3.0.1 +info: + title: China + version: 1.0.0 +servers: + - url: https://microsoftgraph.chinacloudapi.cn/v1.0 +paths: + /users/{user-id}/messages: + get: + operationId: users.ListMessages + responses: + '200': + description: ok + /deviceManagement/managedDevices: + get: + operationId: deviceManagement.ListManagedDevices + responses: + '200': + description: ok diff --git a/test/test-data/openapi-global.yml b/test/test-data/openapi-global.yml new file mode 100644 index 0000000..86bc636 --- /dev/null +++ b/test/test-data/openapi-global.yml @@ -0,0 +1,31 @@ +openapi: 3.0.1 +info: + title: Global + version: 1.0.0 +servers: + - url: https://graph.microsoft.com/v1.0 +paths: + /users/{user-id}/messages: + get: + operationId: users.ListMessages + responses: + '200': + description: ok + /security/alerts_v2/{alert-id}: + get: + operationId: security.GetAlert + responses: + '200': + description: ok + /deviceManagement/managedDevices: + get: + operationId: deviceManagement.ListManagedDevices + responses: + '200': + description: ok + /reports/getUsage(): + get: + operationId: reports.getUsage + responses: + '200': + description: ok diff --git a/test/test-data/openapi-usgov.yml b/test/test-data/openapi-usgov.yml new file mode 100644 index 0000000..5eef203 --- /dev/null +++ b/test/test-data/openapi-usgov.yml @@ -0,0 +1,13 @@ +openapi: 3.0.1 +info: + title: UsGov + version: 1.0.0 +servers: + - url: https://graph.microsoft.us/v1.0 +paths: + /users/{user-id}/messages: + get: + operationId: users.ListMessages + responses: + '200': + description: ok diff --git a/test/test-data/test-exclusions.json b/test/test-data/test-exclusions.json new file mode 100644 index 0000000..8d17ec7 --- /dev/null +++ b/test/test-data/test-exclusions.json @@ -0,0 +1,11 @@ +[ + { + "apiPath": "/appCatalogs/teamsApps", + "operation": "GET", + "cloud": "China" + }, + { + "fileName": "excluded-file.md", + "cloud": "UsGov" + } +] diff --git a/test/test-data/test-l5-exclusion.json b/test/test-data/test-l5-exclusion.json new file mode 100644 index 0000000..ae56f51 --- /dev/null +++ b/test/test-data/test-l5-exclusion.json @@ -0,0 +1,7 @@ +[ + { + "apiPath": "/users/{user-id}/messages", + "operation": "GET", + "cloud": "UsGovL5" + } +] diff --git a/test/test-data/test-overrides.json b/test/test-data/test-overrides.json new file mode 100644 index 0000000..ac204dc --- /dev/null +++ b/test/test-data/test-overrides.json @@ -0,0 +1,11 @@ +[ + { + "apiPath": "/places/{id}", + "overridePath": "/places/{id}/graph.room", + "operation": "GET" + }, + { + "apiPath": "/sites/{id}/pages/microsoft.graph.sitePage", + "overridePath": "/sites/{id}/pages" + } +]