< 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
90%
Covered lines: 166
Uncovered lines: 17
Coverable lines: 183
Total lines: 344
Line coverage: 90.7%
Branch coverage
83%
Covered branches: 45
Total branches: 54
Branch coverage: 83.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
BuildBreadcrumbsAsync()100%88100%
BuildPathAsync()100%11100%
BuildDisplayPathAsync()100%44100%
WalkAncestorsAsync()87.5%161691.3%
ResolveWorldBreadcrumbAsync()83.33%6696.87%
ResolveVirtualGroupsAsync()70%222083.33%

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 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
 1720    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    {
 1429        options ??= new HierarchyWalkOptions();
 30
 31        // 1. Walk up the parent chain
 1432        var articleBreadcrumbs = await WalkAncestorsAsync(articleId, options);
 33
 34        // 2. Resolve world breadcrumb
 1435        var result = new List<BreadcrumbDto>();
 36
 1437        if (options.IncludeWorldBreadcrumb)
 38        {
 1339            var worldBreadcrumb = await ResolveWorldBreadcrumbAsync(articleId, options);
 1340            if (worldBreadcrumb != null)
 41            {
 1242                result.Add(worldBreadcrumb);
 43            }
 44        }
 45
 46        // 3. Insert virtual group breadcrumbs if requested
 1447        if (options.IncludeVirtualGroups)
 48        {
 349            var virtualGroups = await ResolveVirtualGroupsAsync(articleId, articleBreadcrumbs, options);
 350            result.AddRange(virtualGroups);
 51        }
 52
 53        // 4. Append the article chain
 1454        result.AddRange(articleBreadcrumbs);
 55
 1456        return result;
 1457    }
 58
 59    /// <inheritdoc />
 60    public async Task<string> BuildPathAsync(Guid articleId, HierarchyWalkOptions? options = null)
 61    {
 162        var breadcrumbs = await BuildBreadcrumbsAsync(articleId, options);
 563        return string.Join("/", breadcrumbs.Select(b => b.Slug));
 164    }
 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
 370        var options = new HierarchyWalkOptions
 371        {
 372            PublicOnly = false,
 373            IncludeWorldBreadcrumb = false,
 374            IncludeVirtualGroups = false,
 375            IncludeCurrentArticle = true
 376        };
 77
 378        var breadcrumbs = await WalkAncestorsAsync(articleId, options);
 79
 1080        var titles = breadcrumbs.Select(b => b.Title).ToList();
 81
 82        // Strip the first level (top-level article / world root) when requested
 383        if (stripFirstLevel && titles.Count > 1)
 84        {
 185            titles.RemoveAt(0);
 86        }
 87
 388        return string.Join(" / ", titles);
 389    }
 90
 91    // ────────────────────────────────────────────────────────────────
 92    //  Core walk algorithm
 93    // ────────────────────────────────────────────────────────────────
 94
 95    /// <summary>
 96    /// Walks up the parent chain from <paramref name="articleId"/> to the root,
 97    /// collecting breadcrumbs in root-to-leaf order.
 98    /// Includes cycle protection via a visited set and a hard depth limit.
 99    /// </summary>
 100    private async Task<List<BreadcrumbDto>> WalkAncestorsAsync(Guid articleId, HierarchyWalkOptions options)
 101    {
 17102        var breadcrumbs = new List<BreadcrumbDto>();
 17103        var visited = new HashSet<Guid>();
 17104        var currentId = (Guid?)articleId;
 105
 48106        while (currentId.HasValue)
 107        {
 108            // Cycle detection
 35109            if (!visited.Add(currentId.Value))
 110            {
 1111                _logger.LogError(
 1112                    "Cycle detected in article hierarchy at {ArticleId}. Visited: {Visited}",
 1113                    currentId.Value, string.Join(", ", visited));
 1114                break;
 115            }
 116
 117            // Hard depth limit
 34118            if (visited.Count > MaxDepth)
 119            {
 0120                _logger.LogError(
 0121                    "Max hierarchy depth ({MaxDepth}) exceeded walking from article {ArticleId}",
 0122                    MaxDepth, articleId);
 0123                break;
 124            }
 125
 126            // Build the base query
 34127            IQueryable<Chronicis.Shared.Models.Article> query = _context.Articles
 34128                .AsNoTracking()
 34129                .Where(a => a.Id == currentId);
 130
 34131            if (options.PublicOnly)
 132            {
 8133                query = query.Where(a => a.Visibility == ArticleVisibility.Public);
 134            }
 135
 34136            var article = await query
 34137                .Select(a => new
 34138                {
 34139                    a.Id,
 34140                    a.Title,
 34141                    a.Slug,
 34142                    a.ParentId,
 34143                    a.Type,
 34144                    a.WorldId,
 34145                    a.CampaignId,
 34146                    a.ArcId
 34147                })
 34148                .FirstOrDefaultAsync();
 149
 34150            if (article == null)
 151                break;
 152
 153            // Skip the current article if the caller only wants ancestors
 31154            var isTarget = (article.Id == articleId);
 31155            if (!isTarget || options.IncludeCurrentArticle)
 156            {
 30157                breadcrumbs.Insert(0, new BreadcrumbDto
 30158                {
 30159                    Id = article.Id,
 30160                    Title = article.Title ?? "(Untitled)",
 30161                    Slug = article.Slug,
 30162                    Type = article.Type,
 30163                    IsWorld = false
 30164                });
 165            }
 166
 31167            currentId = article.ParentId;
 168        }
 169
 17170        return breadcrumbs;
 17171    }
 172
 173    // ────────────────────────────────────────────────────────────────
 174    //  World breadcrumb resolution
 175    // ────────────────────────────────────────────────────────────────
 176
 177    private async Task<BreadcrumbDto?> ResolveWorldBreadcrumbAsync(Guid articleId, HierarchyWalkOptions options)
 178    {
 179        // Use pre-resolved world if supplied
 13180        if (options.World != null)
 181        {
 4182            return new BreadcrumbDto
 4183            {
 4184                Id = options.World.Id,
 4185                Title = options.World.Name,
 4186                Slug = options.World.Slug,
 4187                Type = default,
 4188                IsWorld = true
 4189            };
 190        }
 191
 192        // Otherwise look it up from the article's WorldId
 9193        var worldId = await _context.Articles
 9194            .AsNoTracking()
 9195            .Where(a => a.Id == articleId)
 9196            .Select(a => a.WorldId)
 9197            .FirstOrDefaultAsync();
 198
 9199        if (!worldId.HasValue)
 1200            return null;
 201
 8202        var world = await _context.Worlds
 8203            .AsNoTracking()
 8204            .Where(w => w.Id == worldId.Value)
 8205            .Select(w => new { w.Id, w.Name, w.Slug })
 8206            .FirstOrDefaultAsync();
 207
 8208        if (world == null)
 0209            return null;
 210
 8211        return new BreadcrumbDto
 8212        {
 8213            Id = world.Id,
 8214            Title = world.Name,
 8215            Slug = world.Slug,
 8216            Type = default,
 8217            IsWorld = true
 8218        };
 13219    }
 220
 221    // ────────────────────────────────────────────────────────────────
 222    //  Virtual group resolution (public world breadcrumbs)
 223    // ────────────────────────────────────────────────────────────────
 224
 225    /// <summary>
 226    /// Resolves virtual group breadcrumbs (Campaigns/Arc or Player Characters/Wiki)
 227    /// based on the article's type and campaign/arc associations.
 228    /// Mirrors the prior behaviour of PublicWorldService.BuildPublicBreadcrumbsAsync.
 229    /// </summary>
 230    private async Task<List<BreadcrumbDto>> ResolveVirtualGroupsAsync(
 231        Guid articleId,
 232        List<BreadcrumbDto> articleBreadcrumbs,
 233        HierarchyWalkOptions options)
 234    {
 3235        var groups = new List<BreadcrumbDto>();
 236
 3237        var targetArticle = await _context.Articles
 3238            .AsNoTracking()
 3239            .Where(a => a.Id == articleId)
 3240            .Select(a => new { a.CampaignId, a.ArcId, a.ParentId, a.Type })
 3241            .FirstOrDefaultAsync();
 242
 3243        if (targetArticle == null)
 0244            return groups;
 245
 3246        if (targetArticle.CampaignId.HasValue)
 247        {
 248            // Session-type article: add Campaign breadcrumb
 1249            var campaign = await _context.Campaigns
 1250                .AsNoTracking()
 1251                .Where(c => c.Id == targetArticle.CampaignId.Value)
 1252                .Select(c => new { c.Id, c.Name })
 1253                .FirstOrDefaultAsync();
 254
 1255            if (campaign != null)
 256            {
 1257                groups.Add(new BreadcrumbDto
 1258                {
 1259                    Id = campaign.Id,
 1260                    Title = campaign.Name,
 1261                    Slug = campaign.Name.ToLowerInvariant().Replace(" ", "-"),
 1262                    Type = default,
 1263                    IsWorld = false
 1264                });
 265            }
 266
 267            // Add Arc breadcrumb if present
 1268            if (targetArticle.ArcId.HasValue)
 269            {
 1270                var arc = await _context.Arcs
 1271                    .AsNoTracking()
 1272                    .Where(a => a.Id == targetArticle.ArcId.Value)
 1273                    .Select(a => new { a.Id, a.Name })
 1274                    .FirstOrDefaultAsync();
 275
 1276                if (arc != null)
 277                {
 1278                    groups.Add(new BreadcrumbDto
 1279                    {
 1280                        Id = arc.Id,
 1281                        Title = arc.Name,
 1282                        Slug = arc.Name.ToLowerInvariant().Replace(" ", "-"),
 1283                        Type = default,
 1284                        IsWorld = false
 1285                    });
 286                }
 287            }
 288        }
 289        else
 290        {
 291            // Non-session: determine root article type by walking up the collected breadcrumbs
 2292            var rootArticleType = targetArticle.Type;
 2293            if (articleBreadcrumbs.Count > 0)
 294            {
 2295                rootArticleType = articleBreadcrumbs[0].Type;
 296            }
 297            else
 298            {
 299                // Breadcrumbs may be empty if IncludeCurrentArticle was false;
 300                // walk up manually to find root type
 0301                var currentParentId = targetArticle.ParentId;
 0302                while (currentParentId.HasValue)
 303                {
 0304                    var parentArticle = await _context.Articles
 0305                        .AsNoTracking()
 0306                        .Where(a => a.Id == currentParentId.Value)
 0307                        .Select(a => new { a.Type, a.ParentId })
 0308                        .FirstOrDefaultAsync();
 309
 0310                    if (parentArticle == null)
 311                        break;
 312
 0313                    rootArticleType = parentArticle.Type;
 0314                    currentParentId = parentArticle.ParentId;
 315                }
 0316            }
 317
 2318            if (rootArticleType == ArticleType.Character)
 319            {
 1320                groups.Add(new BreadcrumbDto
 1321                {
 1322                    Id = Guid.Empty,
 1323                    Title = "Player Characters",
 1324                    Slug = "characters",
 1325                    Type = default,
 1326                    IsWorld = false
 1327                });
 328            }
 1329            else if (rootArticleType == ArticleType.WikiArticle)
 330            {
 1331                groups.Add(new BreadcrumbDto
 1332                {
 1333                    Id = Guid.Empty,
 1334                    Title = "Wiki",
 1335                    Slug = "wiki",
 1336                    Type = default,
 1337                    IsWorld = false
 1338                });
 339            }
 340        }
 341
 3342        return groups;
 3343    }
 344}