From 446b20896499792b02ef11da7a43c43323c14723 Mon Sep 17 00:00:00 2001 From: Samuel Glauser Date: Tue, 14 Apr 2026 15:13:26 +0200 Subject: [PATCH] Changes to layout areas --- .../OrganizationLayoutAreas.cs | 14 +- .../OrganizationNodeType.cs | 1 + .../Data/ACME/Documentation/GettingStarted.md | 6 +- .../Graph/Data/ACME/Documentation/Overview.md | 8 +- samples/Graph/Data/ACME/index.md | 18 +- .../Cornerstone/Documentation/Overview.md | 8 +- samples/Graph/Data/Cornerstone/index.md | 28 +- .../Data/Northwind/Documentation/Overview.md | 8 +- samples/Graph/Data/Northwind/index.md | 16 +- .../Components/SearchHub.cs | 2 +- .../Security/AccessControlPipeline.cs | 5 + .../LinkUrlCleanupExtension.cs | 12 +- .../AccessContext.cs | 6 + src/MeshWeaver.Messaging.Hub/AccessService.cs | 3 +- src/MeshWeaver.Messaging.Hub/PostOptions.cs | 3 +- .../OrganizationOverviewTest.cs | 306 ++++++++++++++++++ .../TodoBoardThumbnailTest.cs | 179 ++++++++++ 17 files changed, 564 insertions(+), 59 deletions(-) create mode 100644 test/MeshWeaver.Acme.Test/OrganizationOverviewTest.cs create mode 100644 test/MeshWeaver.Acme.Test/TodoBoardThumbnailTest.cs diff --git a/memex/Memex.Portal.Shared/OrganizationLayoutAreas.cs b/memex/Memex.Portal.Shared/OrganizationLayoutAreas.cs index 83efad56f..13e787f16 100644 --- a/memex/Memex.Portal.Shared/OrganizationLayoutAreas.cs +++ b/memex/Memex.Portal.Shared/OrganizationLayoutAreas.cs @@ -21,18 +21,18 @@ public static class OrganizationLayoutAreas { var hubPath = host.Hub.Address.ToString(); - var orgStream = host.Workspace.GetStream() - ?.Select(orgs => orgs?.FirstOrDefault()) - ?? Observable.Return(null); - var nodeStream = host.Workspace.GetStream() ?.Select(nodes => nodes?.FirstOrDefault(n => n.Path == hubPath)) ?? Observable.Return(null); - return orgStream.CombineLatest(nodeStream).SelectMany(async t => + var orgStream = host.Workspace.GetStream() + ?.Select(orgs => orgs?.FirstOrDefault()) + ?? Observable.Return(null); + + return nodeStream.CombineLatest(orgStream).SelectMany(async t => { - var (org, node) = t; - if (org == null && node == null) + var (node, org) = t; + if (node == null) return Controls.Markdown("*Loading...*") as UiControl; var perms = await PermissionHelper.GetEffectivePermissionsAsync(host.Hub, hubPath); diff --git a/memex/Memex.Portal.Shared/OrganizationNodeType.cs b/memex/Memex.Portal.Shared/OrganizationNodeType.cs index 6f9b470d7..de0da59bd 100644 --- a/memex/Memex.Portal.Shared/OrganizationNodeType.cs +++ b/memex/Memex.Portal.Shared/OrganizationNodeType.cs @@ -92,6 +92,7 @@ public IEnumerable GetStaticNodes() .AddContentCollections() .AddNodeTypeLayoutAreas() .AddLayout(layout => layout + .WithDefaultArea(MeshNodeLayoutAreas.OverviewArea) .WithView(MeshNodeLayoutAreas.OverviewArea, OrganizationLayoutAreas.Overview)) }; diff --git a/samples/Graph/Data/ACME/Documentation/GettingStarted.md b/samples/Graph/Data/ACME/Documentation/GettingStarted.md index 4af05984b..be6a1465e 100644 --- a/samples/Graph/Data/ACME/Documentation/GettingStarted.md +++ b/samples/Graph/Data/ACME/Documentation/GettingStarted.md @@ -242,9 +242,9 @@ Key features: For detailed understanding of MeshWeaver's architecture in the context of the Software Sample Organization: -- **[Understanding MeshWeaver Architecture](ACME/Documentation/Architecture)**: Message hubs, MVVM patterns, reactive design -- **[AI Agent Integration](ACME/Documentation/AIAgentIntegration)**: How AI agents work with MeshWeaver -- **[Unified References](ACME/Documentation/UnifiedReferences)**: Path syntax and reference patterns +- **[Understanding MeshWeaver Architecture](@ACME/Documentation/Architecture)**: Message hubs, MVVM patterns, reactive design +- **[AI Agent Integration](@ACME/Documentation/AIAgentIntegration)**: How AI agents work with MeshWeaver +- **[Unified References](@ACME/Documentation/UnifiedReferences)**: Path syntax and reference patterns # Next Steps diff --git a/samples/Graph/Data/ACME/Documentation/Overview.md b/samples/Graph/Data/ACME/Documentation/Overview.md index 0b18692bb..f7ae9a95b 100644 --- a/samples/Graph/Data/ACME/Documentation/Overview.md +++ b/samples/Graph/Data/ACME/Documentation/Overview.md @@ -19,10 +19,10 @@ The Software sample organization demonstrates MeshWeaver capabilities through re | Topic | Go here | |-------|---------| -| Get up and running | [Getting Started](ACME/Documentation/GettingStarted) - Setup, navigation, first steps | -| Understand the architecture | [Architecture](ACME/Documentation/Architecture) - MeshNodes, namespaces, message hubs | -| Add AI to your app | [AI Agent Integration](ACME/Documentation/AIAgentIntegration) - Agents, MeshPlugin, NLP | -| Reference paths and queries | [Unified References](ACME/Documentation/UnifiedReferences) - Paths, queries, layout areas | +| Get up and running | [Getting Started](@ACME/Documentation/GettingStarted) - Setup, navigation, first steps | +| Understand the architecture | [Architecture](@ACME/Documentation/Architecture) - MeshNodes, namespaces, message hubs | +| Add AI to your app | [AI Agent Integration](@ACME/Documentation/AIAgentIntegration) - Agents, MeshPlugin, NLP | +| Reference paths and queries | [Unified References](@ACME/Documentation/UnifiedReferences) - Paths, queries, layout areas | --- diff --git a/samples/Graph/Data/ACME/index.md b/samples/Graph/Data/ACME/index.md index 2452cddd4..b455d7aa2 100644 --- a/samples/Graph/Data/ACME/index.md +++ b/samples/Graph/Data/ACME/index.md @@ -12,10 +12,10 @@ Welcome to ACME, a task management demo showcasing MeshWeaver's collaborative pr | Resource | Description | |----------|-------------| -| [CustomerOnboarding](ACME/CustomerOnboarding) | Insurance onboarding workflow with compliance tasks | -| [ProductLaunch](ACME/ProductLaunch) | Marketing campaign with cross-functional tasks | -| [Getting Started](ACME/Documentation/GettingStarted) | Setup and first steps | -| [Documentation](ACME/Documentation) | Complete guides and references | +| [CustomerOnboarding](@ACME/CustomerOnboarding) | Insurance onboarding workflow with compliance tasks | +| [ProductLaunch](@ACME/ProductLaunch) | Marketing campaign with cross-functional tasks | +| [Getting Started](@ACME/Documentation/GettingStarted) | Setup and first steps | +| [Documentation](@ACME/Documentation) | Complete guides and references | --- @@ -39,8 +39,8 @@ ACME (Organization) | Project | Focus | Tasks | |---------|-------|-------| -| [CustomerOnboarding](ACME/CustomerOnboarding) | Insurance compliance | KYC review, risk scoring, policy generation | -| [ProductLaunch](ACME/ProductLaunch) | Marketing campaign | Landing pages, demos, sales training | +| [CustomerOnboarding](@ACME/CustomerOnboarding) | Insurance compliance | KYC review, risk scoring, policy generation | +| [ProductLaunch](@ACME/ProductLaunch) | Marketing campaign | Landing pages, demos, sales training | --- @@ -90,6 +90,6 @@ Projects include multiple perspectives for task management: | Topic | Link | |-------|------| -| Architecture | [How ACME is built](ACME/Documentation/Architecture) | -| AI Integration | [Using the TodoAgent](ACME/Documentation/AIAgentIntegration) | -| References | [Paths, queries, and areas](ACME/Documentation/UnifiedReferences) | +| Architecture | [How ACME is built](@ACME/Documentation/Architecture) | +| AI Integration | [Using the TodoAgent](@ACME/Documentation/AIAgentIntegration) | +| References | [Paths, queries, and areas](@ACME/Documentation/UnifiedReferences) | diff --git a/samples/Graph/Data/Cornerstone/Documentation/Overview.md b/samples/Graph/Data/Cornerstone/Documentation/Overview.md index 9d044937c..9f3d0ce88 100644 --- a/samples/Graph/Data/Cornerstone/Documentation/Overview.md +++ b/samples/Graph/Data/Cornerstone/Documentation/Overview.md @@ -19,10 +19,10 @@ The Cornerstone sample organization demonstrates MeshWeaver capabilities through | Topic | Go here | |-------|---------| -| Get up and running | [Getting Started](Cornerstone/Documentation/GettingStarted) - Setup, navigation, first steps | -| Understand the architecture | [Architecture](Cornerstone/Documentation/Architecture) - Data model, NodeTypes, pricing pipeline | -| Add AI to your app | [AI Agent Integration](Cornerstone/Documentation/AIAgentIntegration) - Pricing assistant, risk queries | -| Reference paths and queries | [Unified References](Cornerstone/Documentation/UnifiedReferences) - Paths, queries, layout areas | +| Get up and running | [Getting Started](@Cornerstone/Documentation/GettingStarted) - Setup, navigation, first steps | +| Understand the architecture | [Architecture](@Cornerstone/Documentation/Architecture) - Data model, NodeTypes, pricing pipeline | +| Add AI to your app | [AI Agent Integration](@Cornerstone/Documentation/AIAgentIntegration) - Pricing assistant, risk queries | +| Reference paths and queries | [Unified References](@Cornerstone/Documentation/UnifiedReferences) - Paths, queries, layout areas | --- diff --git a/samples/Graph/Data/Cornerstone/index.md b/samples/Graph/Data/Cornerstone/index.md index ddf8c725e..546209b6e 100644 --- a/samples/Graph/Data/Cornerstone/index.md +++ b/samples/Graph/Data/Cornerstone/index.md @@ -12,11 +12,11 @@ Welcome to Cornerstone, a reinsurance pricing demo showcasing MeshWeaver's capab | Resource | Description | |----------|-------------| -| [Microsoft](Cornerstone/Microsoft) | Technology sector pricing with full sample data | -| [Tesla](Cornerstone/Tesla) | Industrial and specialty lines | -| [Nestle](Cornerstone/Nestle) | Consumer and agricultural coverage | -| [Getting Started](Cornerstone/Documentation/GettingStarted) | Setup and first steps | -| [Documentation](Cornerstone/Documentation) | Complete guides and references | +| [Microsoft](@Cornerstone/Microsoft) | Technology sector pricing with full sample data | +| [Tesla](@Cornerstone/Tesla) | Industrial and specialty lines | +| [Nestle](@Cornerstone/Nestle) | Consumer and agricultural coverage | +| [Getting Started](@Cornerstone/Documentation/GettingStarted) | Setup and first steps | +| [Documentation](@Cornerstone/Documentation) | Complete guides and references | --- @@ -40,12 +40,12 @@ Each insured company contains yearly pricing instances with property risks, rein | Insured | Sector | Status | |---------|--------|--------| -| [Microsoft](Cornerstone/Microsoft) | Technology | Full sample data (2026) | -| [Tesla](Cornerstone/Tesla) | Industrial | Sample pricing (2026) | -| [Nestle](Cornerstone/Nestle) | Consumer/Agriculture | Sample pricing (2026) | -| [GlobalManufacturing](Cornerstone/GlobalManufacturing) | Manufacturing | Historical (2024) | -| [EuropeanLogistics](Cornerstone/EuropeanLogistics) | Logistics | Historical (2024) | -| [TechIndustries](Cornerstone/TechIndustries) | Technology | Historical (2024) | +| [Microsoft](@Cornerstone/Microsoft) | Technology | Full sample data (2026) | +| [Tesla](@Cornerstone/Tesla) | Industrial | Sample pricing (2026) | +| [Nestle](@Cornerstone/Nestle) | Consumer/Agriculture | Sample pricing (2026) | +| [GlobalManufacturing](@Cornerstone/GlobalManufacturing) | Manufacturing | Historical (2024) | +| [EuropeanLogistics](@Cornerstone/EuropeanLogistics) | Logistics | Historical (2024) | +| [TechIndustries](@Cornerstone/TechIndustries) | Technology | Historical (2024) | --- @@ -97,6 +97,6 @@ Pricings use standardized dimensions for filtering and aggregation: | Topic | Link | |-------|------| -| Architecture | [How Cornerstone is built](Cornerstone/Documentation/Architecture) | -| AI Integration | [Using the pricing assistant](Cornerstone/Documentation/AIAgentIntegration) | -| References | [Paths, queries, and areas](Cornerstone/Documentation/UnifiedReferences) | +| Architecture | [How Cornerstone is built](@Cornerstone/Documentation/Architecture) | +| AI Integration | [Using the pricing assistant](@Cornerstone/Documentation/AIAgentIntegration) | +| References | [Paths, queries, and areas](@Cornerstone/Documentation/UnifiedReferences) | diff --git a/samples/Graph/Data/Northwind/Documentation/Overview.md b/samples/Graph/Data/Northwind/Documentation/Overview.md index 0452872d0..73511c483 100644 --- a/samples/Graph/Data/Northwind/Documentation/Overview.md +++ b/samples/Graph/Data/Northwind/Documentation/Overview.md @@ -19,10 +19,10 @@ The Northwind sample demonstrates MeshWeaver capabilities through a realistic go | Topic | Go here | |-------|---------| -| Get up and running | [Getting Started](Northwind/Documentation/GettingStarted) - Setup, navigation, first steps | -| Understand the architecture | [Architecture](Northwind/Documentation/Architecture) - Data model, views, analytics pipeline | -| Add AI to your app | [AI Agent Integration](Northwind/Documentation/AIAgentIntegration) - NorthwindAgent, analytics queries | -| Reference paths and queries | [Unified References](Northwind/Documentation/UnifiedReferences) - Paths, queries, layout areas | +| Get up and running | [Getting Started](@Northwind/Documentation/GettingStarted) - Setup, navigation, first steps | +| Understand the architecture | [Architecture](@Northwind/Documentation/Architecture) - Data model, views, analytics pipeline | +| Add AI to your app | [AI Agent Integration](@Northwind/Documentation/AIAgentIntegration) - NorthwindAgent, analytics queries | +| Reference paths and queries | [Unified References](@Northwind/Documentation/UnifiedReferences) - Paths, queries, layout areas | --- diff --git a/samples/Graph/Data/Northwind/index.md b/samples/Graph/Data/Northwind/index.md index ac71fb0ff..f7fa8de3c 100644 --- a/samples/Graph/Data/Northwind/index.md +++ b/samples/Graph/Data/Northwind/index.md @@ -12,10 +12,10 @@ Welcome to Northwind, a comprehensive analytics demo showcasing MeshWeaver's cap | Resource | Description | |----------|-------------| -| [Analytics](Northwind/Analytics) | 53 interactive dashboards and analytics views | -| [Getting Started](Northwind/Documentation/GettingStarted) | Setup and first steps | -| [Documentation](Northwind/Documentation) | Complete guides and references | -| [Reports](Northwind/Reports) | Pre-built analytical reports | +| [Analytics](@Northwind/Analytics) | 53 interactive dashboards and analytics views | +| [Getting Started](@Northwind/Documentation/GettingStarted) | Setup and first steps | +| [Documentation](@Northwind/Documentation) | Complete guides and references | +| [Reports](@Northwind/Reports) | Pre-built analytical reports | --- @@ -34,7 +34,7 @@ Explore **53 views** organized across 8 categories: - **Suppliers** - Supplier analytics - **Financial** - Financial metrics and profitability -[Open Analytics](Northwind/Analytics) +[Open Analytics](@Northwind/Analytics) --- @@ -88,6 +88,6 @@ Northwind specializes in gourmet foods: | Topic | Link | |-------|------| -| Architecture | [How Northwind is built](Northwind/Documentation/Architecture) | -| AI Integration | [Using the NorthwindAgent](Northwind/Documentation/AIAgentIntegration) | -| References | [Paths, queries, and areas](Northwind/Documentation/UnifiedReferences) | +| Architecture | [How Northwind is built](@Northwind/Documentation/Architecture) | +| AI Integration | [Using the NorthwindAgent](@Northwind/Documentation/AIAgentIntegration) | +| References | [Paths, queries, and areas](@Northwind/Documentation/UnifiedReferences) | diff --git a/src/MeshWeaver.Blazor.Portal/Components/SearchHub.cs b/src/MeshWeaver.Blazor.Portal/Components/SearchHub.cs index 698e9938a..78fa92b1b 100644 --- a/src/MeshWeaver.Blazor.Portal/Components/SearchHub.cs +++ b/src/MeshWeaver.Blazor.Portal/Components/SearchHub.cs @@ -141,7 +141,7 @@ private static async Task ExecuteTextSearchAsync( IMeshService meshService, SearchRequest req, PendingSearch pending, CancellationToken ct) { // Fetch a wider pool so scoring can surface the best matches - var query = $"*{req.Input}* scope:descendants context:search is:main limit:{CandidatePoolSize}"; + var query = $"*{req.Input}* scope:subtree context:search is:main limit:{CandidatePoolSize}"; var candidates = new List(); await foreach (var obj in meshService.QueryAsync( diff --git a/src/MeshWeaver.Hosting/Security/AccessControlPipeline.cs b/src/MeshWeaver.Hosting/Security/AccessControlPipeline.cs index ffed8ef1e..5d68b2dab 100644 --- a/src/MeshWeaver.Hosting/Security/AccessControlPipeline.cs +++ b/src/MeshWeaver.Hosting/Security/AccessControlPipeline.cs @@ -45,6 +45,11 @@ public static MessageHubConfiguration AddAccessControlPipeline(this MessageHubCo if (attr == null) return await next.Invoke(delivery, ct); + // Hub-to-hub communication is trusted infrastructure — skip access control. + // This handles child hubs subscribing to parent data during initialization. + if (delivery.AccessContext is { IsHub: true }) + return await next.Invoke(delivery, ct); + var userId = ResolveIdentity(delivery, accessService); // Log identity resolution details for debugging access issues diff --git a/src/MeshWeaver.Markdown/LinkUrlCleanupExtension.cs b/src/MeshWeaver.Markdown/LinkUrlCleanupExtension.cs index c1dff3b41..1c41d7cb3 100644 --- a/src/MeshWeaver.Markdown/LinkUrlCleanupExtension.cs +++ b/src/MeshWeaver.Markdown/LinkUrlCleanupExtension.cs @@ -27,8 +27,9 @@ private void ResolveLinks(MarkdownDocument document) var url = link.Url; - // Strip leading '@' prefix - if (url.StartsWith('@')) + // Track whether URL had @ prefix (UCR = absolute mesh path) + var wasAtPrefixed = url.StartsWith('@'); + if (wasAtPrefixed) url = url.TrimStart('@'); // Skip external links, anchors, and mailto @@ -52,9 +53,14 @@ private void ResolveLinks(MarkdownDocument document) // Already absolute — keep as-is link.Url = url + fragment; } + else if (wasAtPrefixed && !string.IsNullOrEmpty(url)) + { + // @-prefixed = absolute UCR reference — prepend / directly, no relative resolution + link.Url = $"/{url}" + fragment; + } else if (!string.IsNullOrEmpty(url)) { - // Relative — resolve against current node path, prepend '/' + // Bare relative path — resolve against current node path, prepend '/' var resolved = PathUtils.ResolveRelativePath(url, currentNodePath); link.Url = $"/{resolved}" + fragment; } diff --git a/src/MeshWeaver.Messaging.Contract/AccessContext.cs b/src/MeshWeaver.Messaging.Contract/AccessContext.cs index 262d4c753..e586a7178 100644 --- a/src/MeshWeaver.Messaging.Contract/AccessContext.cs +++ b/src/MeshWeaver.Messaging.Contract/AccessContext.cs @@ -21,4 +21,10 @@ public record AccessContext /// The Api permission flag is required for operations in this context. /// public bool IsApiToken { get; init; } + + /// + /// When true, this context represents a hub identity (set by ImpersonateAsHub). + /// Hub-to-hub communication is trusted infrastructure and bypasses access control. + /// + public bool IsHub { get; init; } } diff --git a/src/MeshWeaver.Messaging.Hub/AccessService.cs b/src/MeshWeaver.Messaging.Hub/AccessService.cs index 70a04629e..6f6a0e712 100644 --- a/src/MeshWeaver.Messaging.Hub/AccessService.cs +++ b/src/MeshWeaver.Messaging.Hub/AccessService.cs @@ -112,7 +112,8 @@ public IDisposable ImpersonateAsHub(IMessageHub hub) return new AccessContextScope(this, new AccessContext { ObjectId = hub.Address.ToFullString(), - Name = hub.Address.ToString() + Name = hub.Address.ToString(), + IsHub = true }); } diff --git a/src/MeshWeaver.Messaging.Hub/PostOptions.cs b/src/MeshWeaver.Messaging.Hub/PostOptions.cs index 86568d23b..ae6b2efd8 100644 --- a/src/MeshWeaver.Messaging.Hub/PostOptions.cs +++ b/src/MeshWeaver.Messaging.Hub/PostOptions.cs @@ -71,7 +71,8 @@ public PostOptions ImpersonateAsHub(Address hubAddress) => this with ImpersonateContext = new AccessContext { ObjectId = hubAddress.ToFullString(), - Name = hubAddress.ToString() + Name = hubAddress.ToString(), + IsHub = true } }; diff --git a/test/MeshWeaver.Acme.Test/OrganizationOverviewTest.cs b/test/MeshWeaver.Acme.Test/OrganizationOverviewTest.cs new file mode 100644 index 000000000..56b486418 --- /dev/null +++ b/test/MeshWeaver.Acme.Test/OrganizationOverviewTest.cs @@ -0,0 +1,306 @@ +using System.IO; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using MeshWeaver.Data; +using MeshWeaver.Graph; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Hosting; +using MeshWeaver.Hosting.Monolith; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Hosting.Persistence; +using MeshWeaver.Layout; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Memex.Portal.Shared; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace MeshWeaver.Acme.Test; + +/// +/// Tests for Organization Overview rendering and search scope behavior. +/// Verifies that organization pages render correctly with only MeshNode data +/// (no Organization entity required) and that search scopes work as expected. +/// +public class OrganizationOverviewTest(ITestOutputHelper output) : MonolithMeshTestBase(output) +{ + private static readonly string SharedCacheDirectory = Path.Combine( + Path.GetTempPath(), + "MeshWeaverOrgOverviewTests", + ".mesh-cache"); + + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) + { + var graphPath = TestPaths.SamplesGraph; + var dataDirectory = TestPaths.SamplesGraphData; + Directory.CreateDirectory(SharedCacheDirectory); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Graph:Storage:SourceType"] = "FileSystem", + ["Graph:Storage:BasePath"] = graphPath + }) + .Build(); + + return builder + .UseMonolithMesh() + .AddPartitionedFileSystemPersistence(dataDirectory) + .AddAcme() + .AddOrganizationType() + .ConfigureServices(services => + { + services.Configure(o => + { + o.CacheDirectory = SharedCacheDirectory; + o.EnableDiskCache = true; + }); + services.AddSingleton(configuration); + return services; + }) + .AddGraph(); + } + + protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) + { + return base.ConfigureClient(configuration) + .AddLayoutClient(); + } + + /// + /// Organization Overview should render when only a MeshNode exists (no Organization entity). + /// This is the core fix: file-based sample orgs like ACME only have MeshNode data. + /// + [Fact(Timeout = 20000)] + public async Task Overview_ShouldRenderWithMeshNodeOnly() + { + var workspace = GetClient().GetWorkspace(); + var reference = new LayoutAreaReference("Overview"); + var orgAddress = new Address("ACME"); + + var stream = workspace.GetRemoteStream( + orgAddress, + reference); + + var control = await stream + .GetControlStream(reference.Area!) + .Where(c => c != null) + .Timeout(10.Seconds()) + .FirstAsync(); + + control.Should().NotBeNull("Organization Overview should render with MeshNode only"); + control.Should().BeOfType("Overview returns a StackControl container"); + } + + /// + /// The rendered Overview should contain child areas (header, divider, children section). + /// This proves the org view built its full structure from MeshNode.Name. + /// + [Fact(Timeout = 20000)] + public async Task Overview_ShouldHaveChildAreas() + { + var workspace = GetClient().GetWorkspace(); + var reference = new LayoutAreaReference("Overview"); + var orgAddress = new Address("ACME"); + + var stream = workspace.GetRemoteStream( + orgAddress, + reference); + + var control = await stream + .GetControlStream(reference.Area!) + .Where(c => c is StackControl { Areas.Count: > 0 }) + .Timeout(10.Seconds()) + .FirstAsync(); + + var stack = control.Should().BeOfType().Subject; + Output.WriteLine($"Overview stack has {stack.Areas.Count} child areas"); + + // BuildOrganizationView creates: headerRow, divider
, optional markdown body, children catalog + // At minimum we expect headerRow + divider + children = 3 areas + stack.Areas.Count.Should().BeGreaterThanOrEqualTo(3, + "Overview should contain header, divider, and children section"); + } + + /// + /// Search with scope:subtree should include the root organization node itself. + /// This verifies the SearchHub fix (changed from scope:descendants to scope:subtree). + /// + [Fact(Timeout = 20000)] + public async Task Search_SubtreeScope_IncludesRootOrganizationNode() + { + var meshQuery = Mesh.ServiceProvider.GetRequiredService(); + var ct = TestContext.Current.CancellationToken; + + var query = "ACME scope:subtree"; + Output.WriteLine($"Querying: {query}"); + + var results = await meshQuery.QueryAsync( + MeshQueryRequest.FromQuery(query), null, ct) + .ToListAsync(ct); + + Output.WriteLine($"Found {results.Count} results:"); + foreach (var node in results.Take(10)) + Output.WriteLine($" - {node.Path} ({node.NodeType})"); + + results.Should().Contain(n => n.Path == "ACME", + "scope:subtree should include the root node itself"); + } + + /// + /// Search with scope:descendants should exclude the root node (only children). + /// This is a regression guard for the original behavior. + /// + [Fact(Timeout = 20000)] + public async Task Search_DescendantsScope_ExcludesRootNode() + { + var meshQuery = Mesh.ServiceProvider.GetRequiredService(); + var ct = TestContext.Current.CancellationToken; + + var query = "ACME scope:descendants path:ACME"; + Output.WriteLine($"Querying: {query}"); + + var results = await meshQuery.QueryAsync( + MeshQueryRequest.FromQuery(query), null, ct) + .ToListAsync(ct); + + Output.WriteLine($"Found {results.Count} results:"); + foreach (var node in results.Take(10)) + Output.WriteLine($" - {node.Path} ({node.NodeType})"); + + // scope:descendants should NOT include the root ACME node itself + results.Where(n => n.Path == "ACME").Should().BeEmpty( + "scope:descendants should exclude the root node, returning only children"); + } + + /// + /// The pre-rendered HTML for ACME's index.md should NOT contain doubled paths like /ACME/ACME/. + /// The @-prefix in links like (@ACME/CustomerOnboarding) is a UCR marker meaning "absolute path", + /// so it should resolve to /ACME/CustomerOnboarding, not /ACME/ACME/CustomerOnboarding. + /// + [Fact(Timeout = 20000)] + public async Task Overview_PreRenderedHtml_ShouldNotContainDoubledPaths() + { + var meshQuery = Mesh.ServiceProvider.GetRequiredService(); + var ct = TestContext.Current.CancellationToken; + + var acmeNode = await meshQuery.QueryAsync( + MeshQueryRequest.FromQuery("path:ACME"), null, ct) + .FirstOrDefaultAsync(ct); + + acmeNode.Should().NotBeNull("ACME node should exist"); + acmeNode!.PreRenderedHtml.Should().NotBeNullOrEmpty("ACME should have pre-rendered HTML"); + + Output.WriteLine($"PreRenderedHtml length: {acmeNode.PreRenderedHtml!.Length}"); + + // Must NOT contain doubled path + acmeNode.PreRenderedHtml.Should().NotContain("/ACME/ACME/", + "@ prefix means absolute UCR path — should not be resolved relatively"); + + // Must contain correct link + acmeNode.PreRenderedHtml.Should().Contain("href=\"/ACME/CustomerOnboarding\"", + "link to CustomerOnboarding should be absolute /ACME/CustomerOnboarding"); + } + + /// + /// All @-prefixed links in ACME's index.md should resolve to the correct absolute paths. + /// + [Fact(Timeout = 20000)] + public async Task Overview_Links_ShouldResolveToCorrectPaths() + { + var meshQuery = Mesh.ServiceProvider.GetRequiredService(); + var ct = TestContext.Current.CancellationToken; + + var acmeNode = await meshQuery.QueryAsync( + MeshQueryRequest.FromQuery("path:ACME"), null, ct) + .FirstOrDefaultAsync(ct); + + acmeNode.Should().NotBeNull(); + var html = acmeNode!.PreRenderedHtml; + html.Should().NotBeNullOrEmpty(); + + Output.WriteLine("Checking all expected links in PreRenderedHtml:"); + + var expectedLinks = new[] + { + "/ACME/CustomerOnboarding", + "/ACME/ProductLaunch", + "/ACME/Documentation/GettingStarted", + "/ACME/Documentation", + "/ACME/Documentation/Architecture", + "/ACME/Documentation/AIAgentIntegration", + "/ACME/Documentation/UnifiedReferences" + }; + + foreach (var link in expectedLinks) + { + Output.WriteLine($" Checking for href=\"{link}\""); + html.Should().Contain($"href=\"{link}\"", + $"link {link} should be present in pre-rendered HTML"); + } + } + + /// + /// Navigating to ACME/CustomerOnboarding should render (not spin). + /// Sanity check that the sub-page node is valid and renderable. + /// CustomerOnboarding is a Project node — use null area to get the default. + /// + [Fact(Timeout = 20000)] + public async Task SubPage_CustomerOnboarding_ShouldRender() + { + var workspace = GetClient().GetWorkspace(); + var reference = new LayoutAreaReference((string?)null); + var address = new Address("ACME/CustomerOnboarding"); + + var stream = workspace.GetRemoteStream( + address, + reference); + + var control = await stream + .GetControlStream("") + .Where(c => c != null) + .Timeout(10.Seconds()) + .FirstAsync(); + + control.Should().NotBeNull("CustomerOnboarding sub-page should render"); + } + + /// + /// When navigating to an Organization without specifying an area, + /// the default area should resolve to Overview (NamedAreaControl pointing to "Overview"), + /// not SearchArea. + /// + [Fact(Timeout = 20000)] + public async Task DefaultArea_ShouldResolveToOverview() + { + var workspace = GetClient().GetWorkspace(); + // No explicit area — use default (null area triggers default resolution) + var reference = new LayoutAreaReference((string?)null); + var orgAddress = new Address("ACME"); + + var stream = workspace.GetRemoteStream( + orgAddress, + reference); + + // When Area is null, the server resolves the default and stores a + // NamedAreaControl at the empty ("") key pointing to the resolved area name. + var control = await stream + .GetControlStream("") + .Where(c => c != null) + .Timeout(10.Seconds()) + .FirstAsync(); + + control.Should().NotBeNull("default area should render content"); + var namedArea = control.Should().BeOfType().Subject; + namedArea.Area.Should().Be("Overview", + "default area should resolve to Overview, not SearchArea"); + } + +} diff --git a/test/MeshWeaver.Acme.Test/TodoBoardThumbnailTest.cs b/test/MeshWeaver.Acme.Test/TodoBoardThumbnailTest.cs new file mode 100644 index 000000000..e79003fb4 --- /dev/null +++ b/test/MeshWeaver.Acme.Test/TodoBoardThumbnailTest.cs @@ -0,0 +1,179 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using Memex.Portal.Shared; +using Memex.Portal.Shared.Settings; +using MeshWeaver.AI; +using MeshWeaver.ContentCollections; +using MeshWeaver.Data; +using MeshWeaver.Graph; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Hosting; +using MeshWeaver.Hosting.Activity; +using MeshWeaver.Hosting.Monolith; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Hosting.Persistence; +using MeshWeaver.Hosting.Security; +using MeshWeaver.Layout; +using MeshWeaver.Layout.Catalog; +using MeshWeaver.Mesh; +using MeshWeaver.Messaging; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace MeshWeaver.Acme.Test; + +/// +/// Tests that Todo board views (TodosByCategory, AllTasks) can render child hub thumbnails +/// without pre-initializing each hub via PingRequest. This reproduces the portal's behavior +/// where LayoutAreaControl items trigger hub creation on render. +/// +public class TodoBoardThumbnailTest(ITestOutputHelper output) : MonolithMeshTestBase(output) +{ + private static readonly string SharedCacheDirectory = Path.Combine( + Path.GetTempPath(), + "MeshWeaverTodoBoardTests", + ".mesh-cache"); + + private static readonly string ContentBasePath = Path.Combine( + Path.GetTempPath(), + "MeshWeaverTodoBoardTests", + "content"); + + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) + { + var graphPath = TestPaths.SamplesGraph; + var dataDirectory = TestPaths.SamplesGraphData; + Directory.CreateDirectory(SharedCacheDirectory); + Directory.CreateDirectory(ContentBasePath); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Graph:Storage:SourceType"] = "FileSystem", + ["Graph:Storage:BasePath"] = graphPath + }) + .Build(); + + return builder + .UseMonolithMesh() + .AddPartitionedFileSystemPersistence(dataDirectory) + .AddAcme() + .ConfigureServices(services => + { + services.Configure(o => + { + o.CacheDirectory = SharedCacheDirectory; + o.EnableDiskCache = true; + }); + services.AddSingleton(configuration); + return services; + }) + // Match portal's ConfigureMemexMesh order exactly: + .AddRowLevelSecurity() + .AddGraph() + .AddOrganizationType() + .AddAI() + // Reproduce the portal's ConfigureDefaultNodeHub from MemexConfiguration.ConfigureMemexMesh + .ConfigureDefaultNodeHub(config => + { + var nodePath = config.Address.ToString(); + var basePath = Path.Combine(ContentBasePath, nodePath); + var nodeContentConfig = new ContentCollectionConfig + { + Name = "content", + SourceType = "FileSystem", + IsEditable = true, + BasePath = basePath, + Settings = new Dictionary + { + ["BasePath"] = basePath + } + }; + return config + .AddContentCollection(_ => nodeContentConfig) + .AddDefaultLayoutAreas() + .AddThreadsLayoutArea() + .AddApiTokensSettingsTab(); + }) + .AddActivityTracking(); + } + + protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) + { + return base.ConfigureClient(configuration) + .AddLayoutClient(); + } + + /// + /// TodosByCategory returns LayoutAreaControl items for each Todo. + /// When rendered, each LayoutAreaControl triggers child hub creation. + /// This test verifies those child hubs initialize successfully and render thumbnails. + /// In the portal, this fails with "Hub 'ACME/ProductLaunch/Todo/X' initialization failed". + /// + [Fact(Timeout = 60000)] + public async Task TodosByCategory_Thumbnails_ShouldInitializeHubs() + { + var client = GetClient(); + var workspace = client.GetWorkspace(); + var projectAddress = new Address("ACME/ProductLaunch"); + + // Step 1: Get TodosByCategory catalog (this works — the Project hub renders fine) + var reference = new LayoutAreaReference("TodosByCategory"); + var stream = workspace.GetRemoteStream( + projectAddress, + reference); + + Output.WriteLine("Waiting for TodosByCategory CatalogControl..."); + var control = await stream + .GetControlStream(reference.Area!) + .Where(c => c is CatalogControl { Groups.Count: > 0 }) + .Timeout(15.Seconds()) + .FirstAsync(); + + var catalog = control.Should().BeOfType().Subject; + Output.WriteLine($"Got CatalogControl with {catalog.Groups.Count} groups"); + + // Step 2: Extract LayoutAreaControl items from catalog groups + var layoutAreaControls = catalog.Groups + .SelectMany(g => g.Items) + .OfType() + .ToList(); + + Output.WriteLine($"Found {layoutAreaControls.Count} LayoutAreaControl items:"); + foreach (var lac in layoutAreaControls.Take(5)) + Output.WriteLine($" - Address={lac.Address}, Area={lac.Reference.Area}"); + + layoutAreaControls.Should().NotBeEmpty("TodosByCategory should contain LayoutAreaControl thumbnail items"); + + // Step 3: For each LayoutAreaControl, create a remote stream (simulating what Blazor does) + // This triggers child hub creation — the exact path that fails in the portal + var firstFew = layoutAreaControls.Take(3).ToList(); + foreach (var lac in firstFew) + { + var todoAddress = new Address(lac.Address.ToString()!); + Output.WriteLine($"Rendering thumbnail for {todoAddress}..."); + + var thumbnailStream = workspace.GetRemoteStream( + todoAddress, + lac.Reference); + + // This is where the portal fails — the child hub initialization faults + var thumbnailValue = await thumbnailStream + .Timeout(15.Seconds()) + .FirstAsync(); + + Output.WriteLine($" Got value for {todoAddress}: {thumbnailValue.Value.ValueKind}"); + thumbnailValue.Value.ValueKind.Should().NotBe(JsonValueKind.Undefined, + $"Thumbnail for {todoAddress} should render without hub initialization failure"); + } + + Output.WriteLine("All thumbnails rendered successfully"); + } +}