< Summary

Information
Class: Chronicis.Client.Services.CachedArticleInfo
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Services/ArticleCacheService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 6
Coverable lines: 6
Total lines: 193
Line coverage: 0%
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
get_ArticleId()100%210%
get_Title()100%210%
get_Slug()100%210%
get_DisplayPath()100%210%
get_Breadcrumbs()100%210%
get_CachedAt()100%210%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Services/ArticleCacheService.cs

#LineLine coverage
 1using Chronicis.Shared.DTOs;
 2
 3namespace Chronicis.Client.Services;
 4
 5/// <summary>
 6/// Cached article information for navigation and tooltips.
 7/// </summary>
 8public class CachedArticleInfo
 9{
 010    public Guid ArticleId { get; set; }
 011    public string Title { get; set; } = string.Empty;
 012    public string Slug { get; set; } = string.Empty;
 013    public string? DisplayPath { get; set; }
 014    public List<BreadcrumbDto>? Breadcrumbs { get; set; }
 015    public DateTime CachedAt { get; set; }
 16}
 17
 18/// <summary>
 19/// Service interface for caching article metadata to reduce API calls.
 20/// </summary>
 21public interface IArticleCacheService
 22{
 23    /// <summary>
 24    /// Gets cached article info, fetching from API if not cached.
 25    /// </summary>
 26    Task<CachedArticleInfo?> GetArticleInfoAsync(Guid articleId);
 27
 28    /// <summary>
 29    /// Gets the display path for an article (from cache if available).
 30    /// </summary>
 31    Task<string?> GetArticlePathAsync(Guid articleId);
 32
 33    /// <summary>
 34    /// Gets the navigation URL path for an article (from cache if available).
 35    /// </summary>
 36    Task<string?> GetNavigationPathAsync(Guid articleId);
 37
 38    /// <summary>
 39    /// Adds or updates an article in the cache (called when article is loaded).
 40    /// </summary>
 41    void CacheArticle(ArticleDto article);
 42
 43    /// <summary>
 44    /// Invalidates all cached data (called on save/delete).
 45    /// </summary>
 46    void InvalidateCache();
 47
 48    /// <summary>
 49    /// Invalidates a specific article from the cache.
 50    /// </summary>
 51    void InvalidateArticle(Guid articleId);
 52}
 53
 54/// <summary>
 55/// In-memory cache for article metadata to reduce API calls for tooltips and navigation.
 56/// </summary>
 57public class ArticleCacheService : IArticleCacheService
 58{
 59    private readonly Dictionary<Guid, CachedArticleInfo> _cache = new();
 60    private readonly IArticleApiService _articleApi;
 61    private readonly ILogger<ArticleCacheService> _logger;
 62    private readonly object _lock = new();
 63
 64    public ArticleCacheService(IArticleApiService articleApi, ILogger<ArticleCacheService> logger)
 65    {
 66        _articleApi = articleApi;
 67        _logger = logger;
 68    }
 69
 70    /// <summary>
 71    /// Gets cached article info, fetching from API if not cached.
 72    /// </summary>
 73    public async Task<CachedArticleInfo?> GetArticleInfoAsync(Guid articleId)
 74    {
 75        // Check cache first
 76        lock (_lock)
 77        {
 78            if (_cache.TryGetValue(articleId, out var cached))
 79            {
 80                _logger.LogDebug("Cache hit for article {ArticleId}", articleId);
 81                return cached;
 82            }
 83        }
 84
 85        // Fetch from API
 86        try
 87        {
 88            _logger.LogDebug("Cache miss for article {ArticleId}, fetching from API", articleId);
 89            var article = await _articleApi.GetArticleAsync(articleId);
 90
 91            if (article == null)
 92            {
 93                return null;
 94            }
 95
 96            // Cache the result
 97            CacheArticle(article);
 98
 99            lock (_lock)
 100            {
 101                return _cache.GetValueOrDefault(articleId);
 102            }
 103        }
 104        catch (Exception ex)
 105        {
 106            _logger.LogError(ex, "Error fetching article {ArticleId} for cache", articleId);
 107            return null;
 108        }
 109    }
 110
 111    /// <summary>
 112    /// Gets the display path for an article (from cache if available).
 113    /// </summary>
 114    public async Task<string?> GetArticlePathAsync(Guid articleId)
 115    {
 116        var info = await GetArticleInfoAsync(articleId);
 117        return info?.DisplayPath;
 118    }
 119
 120    /// <summary>
 121    /// Gets the navigation URL path for an article (from cache if available).
 122    /// </summary>
 123    public async Task<string?> GetNavigationPathAsync(Guid articleId)
 124    {
 125        var info = await GetArticleInfoAsync(articleId);
 126
 127        if (info?.Breadcrumbs != null && info.Breadcrumbs.Any())
 128        {
 129            return string.Join("/", info.Breadcrumbs.Select(b => b.Slug));
 130        }
 131
 132        return info?.Slug;
 133    }
 134
 135    /// <summary>
 136    /// Adds or updates an article in the cache (called when article is loaded).
 137    /// </summary>
 138    public void CacheArticle(ArticleDto article)
 139    {
 140        if (article == null)
 141            return;
 142
 143        var cachedInfo = new CachedArticleInfo
 144        {
 145            ArticleId = article.Id,
 146            Title = article.Title,
 147            Slug = article.Slug,
 148            Breadcrumbs = article.Breadcrumbs,
 149            CachedAt = DateTime.UtcNow
 150        };
 151
 152        // Build display path from breadcrumbs, skipping the World name (first element)
 153        if (article.Breadcrumbs != null && article.Breadcrumbs.Any())
 154        {
 155            var pathSegments = article.Breadcrumbs.Skip(1).Select(b => b.Title);
 156            cachedInfo.DisplayPath = string.Join(" / ", pathSegments);
 157        }
 158
 159        lock (_lock)
 160        {
 161            _cache[article.Id] = cachedInfo;
 162        }
 163
 164        _logger.LogDebug("Cached article {ArticleId}: {Title}", article.Id, article.Title);
 165    }
 166
 167    /// <summary>
 168    /// Invalidates all cached data (called on save/delete).
 169    /// </summary>
 170    public void InvalidateCache()
 171    {
 172        lock (_lock)
 173        {
 174            var count = _cache.Count;
 175            _cache.Clear();
 176            _logger.LogDebug("Invalidated entire article cache ({Count} entries)", count);
 177        }
 178    }
 179
 180    /// <summary>
 181    /// Invalidates a specific article from the cache.
 182    /// </summary>
 183    public void InvalidateArticle(Guid articleId)
 184    {
 185        lock (_lock)
 186        {
 187            if (_cache.Remove(articleId))
 188            {
 189                _logger.LogDebug("Invalidated cached article {ArticleId}", articleId);
 190            }
 191        }
 192    }
 193}