< Summary

Information
Class: Chronicis.Client.Services.ArticleCacheService
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Services/ArticleCacheService.cs
Line coverage
100%
Covered lines: 43
Uncovered lines: 0
Coverable lines: 43
Total lines: 218
Line coverage: 100%
Branch coverage
100%
Covered branches: 12
Total branches: 12
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
CacheArticle(...)100%66100%
TryGetCachedArticle(...)100%44100%
InvalidateCache()100%11100%
InvalidateArticle(...)100%22100%

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{
 10    public Guid ArticleId { get; set; }
 11    public string Title { get; set; } = string.Empty;
 12    public string Slug { get; set; } = string.Empty;
 13    public string? DisplayPath { get; set; }
 14    public List<BreadcrumbDto>? Breadcrumbs { get; set; }
 15    public ArticleDto? FullArticle { get; set; }
 16    public DateTime CachedAt { get; set; }
 17}
 18
 19/// <summary>
 20/// Service interface for caching article metadata to reduce API calls.
 21/// </summary>
 22public interface IArticleCacheService
 23{
 24    /// <summary>
 25    /// Gets cached article info, fetching from API if not cached.
 26    /// </summary>
 27    Task<CachedArticleInfo?> GetArticleInfoAsync(Guid articleId);
 28
 29    /// <summary>
 30    /// Gets the display path for an article (from cache if available).
 31    /// </summary>
 32    Task<string?> GetArticlePathAsync(Guid articleId);
 33
 34    /// <summary>
 35    /// Gets the navigation URL path for an article (from cache if available).
 36    /// </summary>
 37    Task<string?> GetNavigationPathAsync(Guid articleId);
 38
 39    /// <summary>
 40    /// Adds or updates an article in the cache (called when article is loaded).
 41    /// </summary>
 42    void CacheArticle(ArticleDto article);
 43
 44    /// <summary>
 45    /// Tries to get a fully cached article without making an API call.
 46    /// </summary>
 47    bool TryGetCachedArticle(Guid articleId, out ArticleDto? article);
 48
 49    /// <summary>
 50    /// Invalidates all cached data (called on save/delete).
 51    /// </summary>
 52    void InvalidateCache();
 53
 54    /// <summary>
 55    /// Invalidates a specific article from the cache.
 56    /// </summary>
 57    void InvalidateArticle(Guid articleId);
 58}
 59
 60/// <summary>
 61/// In-memory cache for article metadata to reduce API calls for tooltips and navigation.
 62/// </summary>
 63public class ArticleCacheService : IArticleCacheService
 64{
 765    private readonly Dictionary<Guid, CachedArticleInfo> _cache = new();
 66    private readonly IArticleApiService _articleApi;
 67    private readonly ILogger<ArticleCacheService> _logger;
 768    private readonly object _lock = new();
 69
 70    public ArticleCacheService(IArticleApiService articleApi, ILogger<ArticleCacheService> logger)
 71    {
 772        _articleApi = articleApi;
 773        _logger = logger;
 774    }
 75
 76    /// <summary>
 77    /// Gets cached article info, fetching from API if not cached.
 78    /// </summary>
 79    public async Task<CachedArticleInfo?> GetArticleInfoAsync(Guid articleId)
 80    {
 81        // Check cache first
 82        lock (_lock)
 83        {
 84            if (_cache.TryGetValue(articleId, out var cached))
 85            {
 86                _logger.LogDebug("Cache hit for article {ArticleId}", articleId);
 87                return cached;
 88            }
 89        }
 90
 91        // Fetch from API
 92        try
 93        {
 94            _logger.LogDebug("Cache miss for article {ArticleId}, fetching from API", articleId);
 95            var article = await _articleApi.GetArticleAsync(articleId);
 96
 97            if (article == null)
 98            {
 99                return null;
 100            }
 101
 102            // Cache the result
 103            CacheArticle(article);
 104
 105            lock (_lock)
 106            {
 107                return _cache.GetValueOrDefault(articleId);
 108            }
 109        }
 110        catch (Exception ex)
 111        {
 112            _logger.LogError(ex, "Error fetching article {ArticleId} for cache", articleId);
 113            return null;
 114        }
 115    }
 116
 117    /// <summary>
 118    /// Gets the display path for an article (from cache if available).
 119    /// </summary>
 120    public async Task<string?> GetArticlePathAsync(Guid articleId)
 121    {
 122        var info = await GetArticleInfoAsync(articleId);
 123        return info?.DisplayPath;
 124    }
 125
 126    /// <summary>
 127    /// Gets the navigation URL path for an article (from cache if available).
 128    /// </summary>
 129    public async Task<string?> GetNavigationPathAsync(Guid articleId)
 130    {
 131        var info = await GetArticleInfoAsync(articleId);
 132
 133        if (info?.Breadcrumbs != null && info.Breadcrumbs.Any())
 134        {
 135            return string.Join("/", info.Breadcrumbs.Select(b => b.Slug));
 136        }
 137
 138        return info?.Slug;
 139    }
 140
 141    /// <summary>
 142    /// Adds or updates an article in the cache (called when article is loaded).
 143    /// </summary>
 144    public void CacheArticle(ArticleDto article)
 145    {
 10146        if (article == null)
 1147            return;
 148
 9149        var cachedInfo = new CachedArticleInfo
 9150        {
 9151            ArticleId = article.Id,
 9152            Title = article.Title,
 9153            Slug = article.Slug,
 9154            Breadcrumbs = article.Breadcrumbs,
 9155            FullArticle = article,
 9156            CachedAt = DateTime.UtcNow
 9157        };
 158
 159        // Build display path from breadcrumbs, skipping the World name (first element)
 9160        if (article.Breadcrumbs != null && article.Breadcrumbs.Any())
 161        {
 3162            var pathSegments = article.Breadcrumbs.Skip(1).Select(b => b.Title);
 3163            cachedInfo.DisplayPath = string.Join(" / ", pathSegments);
 164        }
 165
 9166        lock (_lock)
 167        {
 9168            _cache[article.Id] = cachedInfo;
 9169        }
 170
 9171        _logger.LogDebug("Cached article {ArticleId}: {Title}", article.Id, article.Title);
 9172    }
 173
 174    /// <summary>
 175    /// Tries to get a fully cached article without making an API call.
 176    /// </summary>
 177    public bool TryGetCachedArticle(Guid articleId, out ArticleDto? article)
 178    {
 2179        lock (_lock)
 180        {
 2181            if (_cache.TryGetValue(articleId, out var cached) && cached.FullArticle != null)
 182            {
 1183                article = cached.FullArticle;
 1184                return true;
 185            }
 1186        }
 187
 1188        article = null;
 1189        return false;
 1190    }
 191
 192    /// <summary>
 193    /// Invalidates all cached data (called on save/delete).
 194    /// </summary>
 195    public void InvalidateCache()
 196    {
 1197        lock (_lock)
 198        {
 1199            var count = _cache.Count;
 1200            _cache.Clear();
 1201            _logger.LogDebug("Invalidated entire article cache ({Count} entries)", count);
 1202        }
 1203    }
 204
 205    /// <summary>
 206    /// Invalidates a specific article from the cache.
 207    /// </summary>
 208    public void InvalidateArticle(Guid articleId)
 209    {
 2210        lock (_lock)
 211        {
 2212            if (_cache.Remove(articleId))
 213            {
 1214                _logger.LogDebug("Invalidated cached article {ArticleId}", articleId);
 215            }
 2216        }
 2217    }
 218}