< 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: 199
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
 35    /// <summary>
 36    /// Adds or updates an article in the cache (called when article is loaded).
 37    /// </summary>
 38    void CacheArticle(ArticleDto article);
 39
 40    /// <summary>
 41    /// Tries to get a fully cached article without making an API call.
 42    /// </summary>
 43    bool TryGetCachedArticle(Guid articleId, out ArticleDto? article);
 44
 45    /// <summary>
 46    /// Invalidates all cached data (called on save/delete).
 47    /// </summary>
 48    void InvalidateCache();
 49
 50    /// <summary>
 51    /// Invalidates a specific article from the cache.
 52    /// </summary>
 53    void InvalidateArticle(Guid articleId);
 54}
 55
 56/// <summary>
 57/// In-memory cache for article metadata to reduce API calls for tooltips and navigation.
 58/// </summary>
 59public class ArticleCacheService : IArticleCacheService
 60{
 661    private readonly Dictionary<Guid, CachedArticleInfo> _cache = new();
 62    private readonly IArticleApiService _articleApi;
 63    private readonly ILogger<ArticleCacheService> _logger;
 664    private readonly object _lock = new();
 65
 66    public ArticleCacheService(IArticleApiService articleApi, ILogger<ArticleCacheService> logger)
 67    {
 668        _articleApi = articleApi;
 669        _logger = logger;
 670    }
 71
 72    /// <summary>
 73    /// Gets cached article info, fetching from API if not cached.
 74    /// </summary>
 75    public async Task<CachedArticleInfo?> GetArticleInfoAsync(Guid articleId)
 76    {
 77        // Check cache first
 78        lock (_lock)
 79        {
 80            if (_cache.TryGetValue(articleId, out var cached))
 81            {
 82                _logger.LogDebug("Cache hit for article {ArticleId}", articleId);
 83                return cached;
 84            }
 85        }
 86
 87        // Fetch from API
 88        try
 89        {
 90            _logger.LogDebug("Cache miss for article {ArticleId}, fetching from API", articleId);
 91            var article = await _articleApi.GetArticleAsync(articleId);
 92
 93            if (article == null)
 94            {
 95                return null;
 96            }
 97
 98            // Cache the result
 99            CacheArticle(article);
 100
 101            lock (_lock)
 102            {
 103                return _cache.GetValueOrDefault(articleId);
 104            }
 105        }
 106        catch (Exception ex)
 107        {
 108            _logger.LogError(ex, "Error fetching article {ArticleId} for cache", articleId);
 109            return null;
 110        }
 111    }
 112
 113    /// <summary>
 114    /// Gets the display path for an article (from cache if available).
 115    /// </summary>
 116    public async Task<string?> GetArticlePathAsync(Guid articleId)
 117    {
 118        var info = await GetArticleInfoAsync(articleId);
 119        return info?.DisplayPath;
 120    }
 121
 122    /// <summary>
 123    /// Adds or updates an article in the cache (called when article is loaded).
 124    /// </summary>
 125    public void CacheArticle(ArticleDto article)
 126    {
 9127        if (article == null)
 1128            return;
 129
 8130        var cachedInfo = new CachedArticleInfo
 8131        {
 8132            ArticleId = article.Id,
 8133            Title = article.Title,
 8134            Slug = article.Slug,
 8135            Breadcrumbs = article.Breadcrumbs,
 8136            FullArticle = article,
 8137            CachedAt = DateTime.UtcNow
 8138        };
 139
 140        // Build display path from breadcrumbs, skipping the World name (first element)
 8141        if (article.Breadcrumbs != null && article.Breadcrumbs.Any())
 142        {
 3143            var pathSegments = article.Breadcrumbs.Skip(1).Select(b => b.Title);
 3144            cachedInfo.DisplayPath = string.Join(" / ", pathSegments);
 145        }
 146
 8147        lock (_lock)
 148        {
 8149            _cache[article.Id] = cachedInfo;
 8150        }
 151
 8152        _logger.LogDebug("Cached article {ArticleId}: {Title}", article.Id, article.Title);
 8153    }
 154
 155    /// <summary>
 156    /// Tries to get a fully cached article without making an API call.
 157    /// </summary>
 158    public bool TryGetCachedArticle(Guid articleId, out ArticleDto? article)
 159    {
 2160        lock (_lock)
 161        {
 2162            if (_cache.TryGetValue(articleId, out var cached) && cached.FullArticle != null)
 163            {
 1164                article = cached.FullArticle;
 1165                return true;
 166            }
 1167        }
 168
 1169        article = null;
 1170        return false;
 1171    }
 172
 173    /// <summary>
 174    /// Invalidates all cached data (called on save/delete).
 175    /// </summary>
 176    public void InvalidateCache()
 177    {
 1178        lock (_lock)
 179        {
 1180            var count = _cache.Count;
 1181            _cache.Clear();
 1182            _logger.LogDebug("Invalidated entire article cache ({Count} entries)", count);
 1183        }
 1184    }
 185
 186    /// <summary>
 187    /// Invalidates a specific article from the cache.
 188    /// </summary>
 189    public void InvalidateArticle(Guid articleId)
 190    {
 2191        lock (_lock)
 192        {
 2193            if (_cache.Remove(articleId))
 194            {
 1195                _logger.LogDebug("Invalidated cached article {ArticleId}", articleId);
 196            }
 2197        }
 2198    }
 199}