< Summary

Information
Class: Chronicis.Api.Services.ArticleHierarchyService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ArticleHierarchyService.cs
Line coverage
100%
Covered lines: 3
Uncovered lines: 0
Coverable lines: 3
Total lines: 409
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ArticleHierarchyService.cs

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Shared.DTOs;
 3using Chronicis.Shared.Enums;
 4using Microsoft.EntityFrameworkCore;
 5
 6namespace Chronicis.Api.Services;
 7
 8/// <inheritdoc />
 9public sealed class ArticleHierarchyService : IArticleHierarchyService
 10{
 11    private readonly ChronicisDbContext _context;
 12    private readonly ILogger<ArticleHierarchyService> _logger;
 13
 14    /// <summary>
 15    /// Safety limit to prevent runaway walks in case of corrupted data.
 16    /// No realistic hierarchy should exceed this depth.
 17    /// </summary>
 18    private const int MaxDepth = 200;
 19
 20    public ArticleHierarchyService(ChronicisDbContext context, ILogger<ArticleHierarchyService> logger)
 21    {
 1722        _context = context;
 1723        _logger = logger;
 1724    }
 25
 26    /// <inheritdoc />
 27    public async Task<List<BreadcrumbDto>> BuildBreadcrumbsAsync(Guid articleId, HierarchyWalkOptions? options = null)
 28    {
 29        options ??= new HierarchyWalkOptions();
 30
 31        // 1. Walk up the parent chain
 32        var articleBreadcrumbs = await WalkAncestorsAsync(articleId, options);
 33
 34        // 2. Resolve world breadcrumb
 35        var result = new List<BreadcrumbDto>();
 36
 37        if (options.IncludeWorldBreadcrumb)
 38        {
 39            var worldBreadcrumb = await ResolveWorldBreadcrumbAsync(articleId, options);
 40            if (worldBreadcrumb != null)
 41            {
 42                result.Add(worldBreadcrumb);
 43            }
 44        }
 45
 46        // 3. Insert virtual group breadcrumbs if requested
 47        if (options.IncludeVirtualGroups)
 48        {
 49            var virtualGroups = await ResolveVirtualGroupsAsync(articleId, articleBreadcrumbs, options);
 50            result.AddRange(virtualGroups);
 51        }
 52
 53        // 4. Append the article chain
 54        result.AddRange(articleBreadcrumbs);
 55
 56        return result;
 57    }
 58
 59    /// <inheritdoc />
 60    public async Task<string> BuildPathAsync(Guid articleId, HierarchyWalkOptions? options = null)
 61    {
 62        var breadcrumbs = await BuildBreadcrumbsAsync(articleId, options);
 63        return string.Join("/", breadcrumbs.Select(b => b.Slug));
 64    }
 65
 66    /// <inheritdoc />
 67    public async Task<string> BuildDisplayPathAsync(Guid articleId, bool stripFirstLevel = true)
 68    {
 69        // Display paths: walk up collecting titles, no world breadcrumb, no virtual groups
 70        var options = new HierarchyWalkOptions
 71        {
 72            PublicOnly = false,
 73            IncludeWorldBreadcrumb = false,
 74            IncludeVirtualGroups = false,
 75            IncludeCurrentArticle = true
 76        };
 77
 78        var breadcrumbs = await WalkAncestorsAsync(articleId, options);
 79
 80        var titles = breadcrumbs.Select(b => b.Title).ToList();
 81
 82        // Strip the first level (top-level article / world root) when requested
 83        if (stripFirstLevel && titles.Count > 1)
 84        {
 85            titles.RemoveAt(0);
 86        }
 87
 88        return string.Join(" / ", titles);
 89    }
 90
 91    // ────────────────────────────────────────────────────────────────
 92    //  Batch breadcrumb resolution
 93    // ────────────────────────────────────────────────────────────────
 94
 95    /// <inheritdoc />
 96    public async Task<Dictionary<Guid, List<BreadcrumbDto>>> BuildBreadcrumbsBatchAsync(
 97        IEnumerable<Guid> articleIds,
 98        HierarchyWalkOptions? options = null)
 99    {
 100        options ??= new HierarchyWalkOptions();
 101
 102        var requestedIds = articleIds.Distinct().ToList();
 103        var result = requestedIds.ToDictionary(id => id, _ => new List<BreadcrumbDto>());
 104
 105        if (requestedIds.Count == 0)
 106            return result;
 107
 108        // Phase 1: Load all article data needed for breadcrumbs in O(depth) bulk queries.
 109        // Each iteration loads one "level" of parents that has not been fetched yet.
 110        var cache = new Dictionary<Guid, (string? Title, string Slug, Guid? ParentId, ArticleType Type)>();
 111        var pending = new HashSet<Guid>(requestedIds);
 112
 113        while (pending.Count > 0)
 114        {
 115            if (cache.Count > 10_000)
 116            {
 117                _logger.LogErrorSanitized(
 118                    "BuildBreadcrumbsBatchAsync: ancestor cache exceeded 10,000 entries, stopping walk");
 119                break;
 120            }
 121
 122            var pendingList = pending.ToList();
 123            var batch = await _context.Articles
 124                .AsNoTracking()
 125                .Where(a => pendingList.Contains(a.Id))
 126                .Select(a => new { a.Id, a.Title, a.Slug, a.ParentId, a.Type })
 127                .ToListAsync();
 128
 129            pending.Clear();
 130
 131            foreach (var node in batch)
 132            {
 133                cache[node.Id] = (node.Title, node.Slug, node.ParentId, node.Type);
 134
 135                if (node.ParentId.HasValue && !cache.ContainsKey(node.ParentId.Value))
 136                    pending.Add(node.ParentId.Value);
 137            }
 138        }
 139
 140        // Phase 2: Build breadcrumbs in memory for each requested article ID.
 141        foreach (var articleId in requestedIds)
 142        {
 143            if (!cache.TryGetValue(articleId, out var article))
 144                continue;
 145
 146            var breadcrumbs = new List<BreadcrumbDto>();
 147            var visited = new HashSet<Guid> { articleId };
 148
 149            // Honour IncludeCurrentArticle: start from the article itself or skip to its parent.
 150            var currentId = options.IncludeCurrentArticle
 151                ? (Guid?)articleId
 152                : article.ParentId;
 153
 154            while (currentId.HasValue)
 155            {
 156                if (!visited.Add(currentId.Value))
 157                    break; // cycle detected
 158
 159                if (!cache.TryGetValue(currentId.Value, out var node))
 160                    break; // parent was not loaded (shouldn't happen within safety cap)
 161
 162                breadcrumbs.Insert(0, new BreadcrumbDto
 163                {
 164                    Id = currentId.Value,
 165                    Title = node.Title ?? "(Untitled)",
 166                    Slug = node.Slug,
 167                    Type = node.Type,
 168                    IsWorld = false
 169                });
 170
 171                currentId = node.ParentId;
 172            }
 173
 174            result[articleId] = breadcrumbs;
 175        }
 176
 177        return result;
 178    }
 179
 180    // ────────────────────────────────────────────────────────────────
 181    //  Core walk algorithm
 182    // ────────────────────────────────────────────────────────────────
 183
 184    /// <summary>
 185    /// Walks up the parent chain from <paramref name="articleId"/> to the root,
 186    /// collecting breadcrumbs in root-to-leaf order.
 187    /// Issues one query per ancestor level; depth is typically 2–5 for real hierarchies.
 188    /// </summary>
 189    private async Task<List<BreadcrumbDto>> WalkAncestorsAsync(Guid articleId, HierarchyWalkOptions options)
 190    {
 191        var breadcrumbs = new List<BreadcrumbDto>();
 192        var visited = new HashSet<Guid>();
 193        var currentId = (Guid?)articleId;
 194
 195        while (currentId.HasValue && visited.Count <= MaxDepth)
 196        {
 197            if (!visited.Add(currentId.Value))
 198            {
 199                _logger.LogErrorSanitized(
 200                    "Cycle detected in article hierarchy at {ArticleId}. Visited: {Visited}",
 201                    currentId.Value, string.Join(", ", visited));
 202                break;
 203            }
 204
 205            IQueryable<Chronicis.Shared.Models.Article> query = _context.Articles
 206                .AsNoTracking()
 207                .Where(a => a.Id == currentId);
 208
 209            if (options.PublicOnly)
 210                query = query.Where(a => a.Visibility == ArticleVisibility.Public);
 211
 212            var article = await query
 213                .Select(a => new { a.Id, a.Title, a.Slug, a.ParentId, a.Type })
 214                .FirstOrDefaultAsync();
 215
 216            if (article == null)
 217                break;
 218
 219            var isTarget = (article.Id == articleId);
 220            if (!isTarget || options.IncludeCurrentArticle)
 221            {
 222                breadcrumbs.Insert(0, new BreadcrumbDto
 223                {
 224                    Id = article.Id,
 225                    Title = article.Title ?? "(Untitled)",
 226                    Slug = article.Slug,
 227                    Type = article.Type,
 228                    IsWorld = false
 229                });
 230            }
 231
 232            currentId = article.ParentId;
 233        }
 234
 235        return breadcrumbs;
 236    }
 237
 238    // ────────────────────────────────────────────────────────────────
 239    //  World breadcrumb resolution
 240    // ────────────────────────────────────────────────────────────────
 241
 242    private async Task<BreadcrumbDto?> ResolveWorldBreadcrumbAsync(Guid articleId, HierarchyWalkOptions options)
 243    {
 244        // Use pre-resolved world if supplied
 245        if (options.World != null)
 246        {
 247            return new BreadcrumbDto
 248            {
 249                Id = options.World.Id,
 250                Title = options.World.Name,
 251                Slug = options.World.Slug,
 252                Type = default,
 253                IsWorld = true
 254            };
 255        }
 256
 257        // Otherwise look it up from the article's WorldId
 258        var worldId = await _context.Articles
 259            .AsNoTracking()
 260            .Where(a => a.Id == articleId)
 261            .Select(a => a.WorldId)
 262            .FirstOrDefaultAsync();
 263
 264        if (!worldId.HasValue)
 265            return null;
 266
 267        var world = await _context.Worlds
 268            .AsNoTracking()
 269            .Where(w => w.Id == worldId.Value)
 270            .Select(w => new { w.Id, w.Name, w.Slug })
 271            .FirstOrDefaultAsync();
 272
 273        if (world == null)
 274            return null;
 275
 276        return new BreadcrumbDto
 277        {
 278            Id = world.Id,
 279            Title = world.Name,
 280            Slug = world.Slug,
 281            Type = default,
 282            IsWorld = true
 283        };
 284    }
 285
 286    // ────────────────────────────────────────────────────────────────
 287    //  Virtual group resolution (public world breadcrumbs)
 288    // ────────────────────────────────────────────────────────────────
 289
 290    /// <summary>
 291    /// Resolves virtual group breadcrumbs (Campaigns/Arc or Player Characters/Wiki)
 292    /// based on the article's type and campaign/arc associations.
 293    /// Mirrors the prior behaviour of PublicWorldService.BuildPublicBreadcrumbsAsync.
 294    /// </summary>
 295    private async Task<List<BreadcrumbDto>> ResolveVirtualGroupsAsync(
 296        Guid articleId,
 297        List<BreadcrumbDto> articleBreadcrumbs,
 298        HierarchyWalkOptions options)
 299    {
 300        var groups = new List<BreadcrumbDto>();
 301
 302        var targetArticle = await _context.Articles
 303            .AsNoTracking()
 304            .Where(a => a.Id == articleId)
 305            .Select(a => new { a.CampaignId, a.ArcId, a.ParentId, a.Type })
 306            .FirstOrDefaultAsync();
 307
 308        if (targetArticle == null)
 309            return groups;
 310
 311        if (targetArticle.CampaignId.HasValue)
 312        {
 313            // Session-type article: add Campaign breadcrumb
 314            var campaign = await _context.Campaigns
 315                .AsNoTracking()
 316                .Where(c => c.Id == targetArticle.CampaignId.Value)
 317                .Select(c => new { c.Id, c.Name })
 318                .FirstOrDefaultAsync();
 319
 320            if (campaign != null)
 321            {
 322                groups.Add(new BreadcrumbDto
 323                {
 324                    Id = campaign.Id,
 325                    Title = campaign.Name,
 326                    Slug = campaign.Name.ToLowerInvariant().Replace(" ", "-"),
 327                    Type = default,
 328                    IsWorld = false
 329                });
 330            }
 331
 332            // Add Arc breadcrumb if present
 333            if (targetArticle.ArcId.HasValue)
 334            {
 335                var arc = await _context.Arcs
 336                    .AsNoTracking()
 337                    .Where(a => a.Id == targetArticle.ArcId.Value)
 338                    .Select(a => new { a.Id, a.Name })
 339                    .FirstOrDefaultAsync();
 340
 341                if (arc != null)
 342                {
 343                    groups.Add(new BreadcrumbDto
 344                    {
 345                        Id = arc.Id,
 346                        Title = arc.Name,
 347                        Slug = arc.Name.ToLowerInvariant().Replace(" ", "-"),
 348                        Type = default,
 349                        IsWorld = false
 350                    });
 351                }
 352            }
 353        }
 354        else
 355        {
 356            // Non-session: determine root article type by walking up the collected breadcrumbs
 357            var rootArticleType = targetArticle.Type;
 358            if (articleBreadcrumbs.Count > 0)
 359            {
 360                rootArticleType = articleBreadcrumbs[0].Type;
 361            }
 362            else
 363            {
 364                // Breadcrumbs may be empty if IncludeCurrentArticle was false;
 365                // walk up manually to find root type
 366                var currentParentId = targetArticle.ParentId;
 367                while (currentParentId.HasValue)
 368                {
 369                    var parentArticle = await _context.Articles
 370                        .AsNoTracking()
 371                        .Where(a => a.Id == currentParentId.Value)
 372                        .Select(a => new { a.Type, a.ParentId })
 373                        .FirstOrDefaultAsync();
 374
 375                    if (parentArticle == null)
 376                        break;
 377
 378                    rootArticleType = parentArticle.Type;
 379                    currentParentId = parentArticle.ParentId;
 380                }
 381            }
 382
 383            if (rootArticleType == ArticleType.Character)
 384            {
 385                groups.Add(new BreadcrumbDto
 386                {
 387                    Id = Guid.Empty,
 388                    Title = "Player Characters",
 389                    Slug = "characters",
 390                    Type = default,
 391                    IsWorld = false
 392                });
 393            }
 394            else if (rootArticleType == ArticleType.WikiArticle)
 395            {
 396                groups.Add(new BreadcrumbDto
 397                {
 398                    Id = Guid.Empty,
 399                    Title = "Wiki",
 400                    Slug = "wiki",
 401                    Type = default,
 402                    IsWorld = false
 403                });
 404            }
 405        }
 406
 407        return groups;
 408    }
 409}