< Summary

Information
Class: Chronicis.Api.Services.TutorialService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/TutorialService.cs
Line coverage
100%
Covered lines: 7
Uncovered lines: 0
Coverable lines: 7
Total lines: 311
Line coverage: 100%
Branch coverage
100%
Covered branches: 2
Total branches: 2
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%
NormalizeRequired(...)100%22100%

File(s)

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

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Api.Infrastructure;
 3using Chronicis.Shared.DTOs;
 4using Chronicis.Shared.Enums;
 5using Chronicis.Shared.Models;
 6using Chronicis.Shared.Utilities;
 7using Microsoft.EntityFrameworkCore;
 8
 9namespace Chronicis.Api.Services;
 10
 11public sealed class TutorialService : ITutorialService
 12{
 13    private const string DefaultPageType = "Page:Default";
 14    private const string ArticleTypePrefix = "ArticleType:";
 15    private const string ArticleTypeAny = "ArticleType:Any";
 16
 17    private readonly ChronicisDbContext _context;
 18    private readonly ICurrentUserService _currentUserService;
 19    private readonly ILogger<TutorialService> _logger;
 20
 21    public TutorialService(
 22        ChronicisDbContext context,
 23        ICurrentUserService currentUserService,
 24        ILogger<TutorialService> logger)
 25    {
 726        _context = context;
 727        _currentUserService = currentUserService;
 728        _logger = logger;
 729    }
 30
 31    public async Task<TutorialDto?> ResolveAsync(string pageType)
 32    {
 33        var normalizedPageType = string.IsNullOrWhiteSpace(pageType)
 34            ? DefaultPageType
 35            : pageType.Trim();
 36
 37        var exact = await TryResolveByPageTypeAsync(normalizedPageType);
 38        if (exact != null)
 39        {
 40            return exact;
 41        }
 42
 43        if (normalizedPageType.StartsWith(ArticleTypePrefix, StringComparison.OrdinalIgnoreCase) &&
 44            !string.Equals(normalizedPageType, ArticleTypeAny, StringComparison.OrdinalIgnoreCase))
 45        {
 46            var anyArticleType = await TryResolveByPageTypeAsync(ArticleTypeAny);
 47            if (anyArticleType != null)
 48            {
 49                return anyArticleType;
 50            }
 51        }
 52
 53        return await TryResolveByPageTypeAsync(DefaultPageType);
 54    }
 55
 56    public async Task<List<TutorialMappingDto>> GetMappingsAsync()
 57    {
 58        await ThrowIfNotSysAdminAsync();
 59
 60        return await _context.TutorialPages
 61            .AsNoTracking()
 62            .Where(tp => tp.Article.Type == ArticleType.Tutorial && tp.Article.WorldId == Guid.Empty)
 63            .Select(tp => new TutorialMappingDto
 64            {
 65                Id = tp.Id,
 66                PageType = tp.PageType,
 67                PageTypeName = tp.PageTypeName,
 68                ArticleId = tp.ArticleId,
 69                Title = tp.Article.Title,
 70                ModifiedAt = tp.Article.ModifiedAt ?? tp.Article.CreatedAt
 71            })
 72            .OrderBy(tp => tp.PageType)
 73            .ToListAsync();
 74    }
 75
 76    public async Task<TutorialMappingDto> CreateMappingAsync(TutorialMappingCreateDto dto)
 77    {
 78        ArgumentNullException.ThrowIfNull(dto);
 79
 80        await ThrowIfNotSysAdminAsync();
 81
 82        var pageType = NormalizeRequired(dto.PageType, nameof(dto.PageType));
 83        var pageTypeName = NormalizeRequired(dto.PageTypeName, nameof(dto.PageTypeName));
 84
 85        var pageTypeExists = await _context.TutorialPages
 86            .AnyAsync(tp => tp.PageType == pageType);
 87        if (pageTypeExists)
 88        {
 89            throw new InvalidOperationException($"A tutorial mapping already exists for page type '{pageType}'.");
 90        }
 91
 92        var now = DateTime.UtcNow;
 93        Article article;
 94
 95        if (dto.ArticleId.HasValue)
 96        {
 97            article = await GetRequiredTutorialArticleAsync(dto.ArticleId.Value);
 98
 99            // Normalize legacy/invalid tutorial rows to the system world sentinel.
 100            if (article.WorldId != Guid.Empty)
 101            {
 102                article.WorldId = Guid.Empty;
 103            }
 104        }
 105        else
 106        {
 107            var user = await _currentUserService.GetRequiredUserAsync();
 108            var title = string.IsNullOrWhiteSpace(dto.Title)
 109                ? pageTypeName
 110                : dto.Title.Trim();
 111            var slug = await GenerateUniqueTutorialSlugAsync(title, parentId: null, excludeArticleId: null);
 112
 113            article = new Article
 114            {
 115                Id = Guid.NewGuid(),
 116                Title = title,
 117                Slug = slug,
 118                Body = dto.Body ?? string.Empty,
 119                Type = ArticleType.Tutorial,
 120                Visibility = ArticleVisibility.Public,
 121                WorldId = Guid.Empty,
 122                CreatedBy = user.Id,
 123                CreatedAt = now,
 124                EffectiveDate = now
 125            };
 126
 127            _context.Articles.Add(article);
 128        }
 129
 130        var mapping = new TutorialPage
 131        {
 132            Id = Guid.NewGuid(),
 133            PageType = pageType,
 134            PageTypeName = pageTypeName,
 135            ArticleId = article.Id,
 136            CreatedAt = now,
 137            ModifiedAt = now
 138        };
 139
 140        _context.TutorialPages.Add(mapping);
 141        await _context.SaveChangesAsync();
 142
 143        _logger.LogTraceSanitized(
 144            "SysAdmin created tutorial mapping {PageType} -> article {ArticleId}",
 145            mapping.PageType,
 146            mapping.ArticleId);
 147
 148        return new TutorialMappingDto
 149        {
 150            Id = mapping.Id,
 151            PageType = mapping.PageType,
 152            PageTypeName = mapping.PageTypeName,
 153            ArticleId = mapping.ArticleId,
 154            Title = article.Title,
 155            ModifiedAt = article.ModifiedAt ?? article.CreatedAt
 156        };
 157    }
 158
 159    public async Task<TutorialMappingDto?> UpdateMappingAsync(Guid id, TutorialMappingUpdateDto dto)
 160    {
 161        ArgumentNullException.ThrowIfNull(dto);
 162
 163        await ThrowIfNotSysAdminAsync();
 164
 165        var pageType = NormalizeRequired(dto.PageType, nameof(dto.PageType));
 166        var pageTypeName = NormalizeRequired(dto.PageTypeName, nameof(dto.PageTypeName));
 167
 168        var mapping = await _context.TutorialPages
 169            .FirstOrDefaultAsync(tp => tp.Id == id);
 170        if (mapping == null)
 171        {
 172            return null;
 173        }
 174
 175        var duplicatePageType = await _context.TutorialPages
 176            .AnyAsync(tp => tp.Id != id && tp.PageType == pageType);
 177        if (duplicatePageType)
 178        {
 179            throw new InvalidOperationException($"A tutorial mapping already exists for page type '{pageType}'.");
 180        }
 181
 182        var article = await GetRequiredTutorialArticleAsync(dto.ArticleId);
 183        if (article.WorldId != Guid.Empty)
 184        {
 185            article.WorldId = Guid.Empty;
 186        }
 187
 188        mapping.PageType = pageType;
 189        mapping.PageTypeName = pageTypeName;
 190        mapping.ArticleId = article.Id;
 191        mapping.ModifiedAt = DateTime.UtcNow;
 192
 193        await _context.SaveChangesAsync();
 194
 195        _logger.LogTraceSanitized(
 196            "SysAdmin updated tutorial mapping {MappingId} ({PageType}) -> article {ArticleId}",
 197            mapping.Id,
 198            mapping.PageType,
 199            mapping.ArticleId);
 200
 201        return new TutorialMappingDto
 202        {
 203            Id = mapping.Id,
 204            PageType = mapping.PageType,
 205            PageTypeName = mapping.PageTypeName,
 206            ArticleId = mapping.ArticleId,
 207            Title = article.Title,
 208            ModifiedAt = article.ModifiedAt ?? article.CreatedAt
 209        };
 210    }
 211
 212    public async Task<bool> DeleteMappingAsync(Guid id)
 213    {
 214        await ThrowIfNotSysAdminAsync();
 215
 216        var mapping = await _context.TutorialPages
 217            .FirstOrDefaultAsync(tp => tp.Id == id);
 218        if (mapping == null)
 219        {
 220            return false;
 221        }
 222
 223        _context.TutorialPages.Remove(mapping);
 224        await _context.SaveChangesAsync();
 225
 226        _logger.LogTraceSanitized("SysAdmin deleted tutorial mapping {MappingId}", id);
 227        return true;
 228    }
 229
 230    private async Task<TutorialDto?> TryResolveByPageTypeAsync(string pageType)
 231    {
 232        return await _context.TutorialPages
 233            .AsNoTracking()
 234            .Where(tp => tp.PageType == pageType)
 235            .Where(tp => tp.Article.Type == ArticleType.Tutorial && tp.Article.WorldId == Guid.Empty)
 236            .Select(tp => new TutorialDto
 237            {
 238                ArticleId = tp.ArticleId,
 239                Title = tp.Article.Title,
 240                Body = tp.Article.Body ?? string.Empty,
 241                ModifiedAt = tp.Article.ModifiedAt ?? tp.Article.CreatedAt
 242            })
 243            .FirstOrDefaultAsync();
 244    }
 245
 246    private async Task ThrowIfNotSysAdminAsync()
 247    {
 248        if (!await _currentUserService.IsSysAdminAsync())
 249        {
 250            throw new UnauthorizedAccessException("Caller is not a system administrator.");
 251        }
 252    }
 253
 254    private async Task<Article> GetRequiredTutorialArticleAsync(Guid articleId)
 255    {
 256        var article = await _context.Articles
 257            .FirstOrDefaultAsync(a => a.Id == articleId);
 258
 259        if (article == null)
 260        {
 261            throw new InvalidOperationException($"Tutorial article {articleId} was not found.");
 262        }
 263
 264        if (article.Type != ArticleType.Tutorial)
 265        {
 266            throw new InvalidOperationException(
 267                $"Article {articleId} is not a tutorial article and cannot be mapped.");
 268        }
 269
 270        return article;
 271    }
 272
 273    private async Task<string> GenerateUniqueTutorialSlugAsync(string title, Guid? parentId, Guid? excludeArticleId)
 274    {
 275        var baseSlug = SlugGenerator.GenerateSlug(title);
 276
 277        var query = _context.Articles
 278            .AsNoTracking()
 279            .Where(a => a.Type == ArticleType.Tutorial && a.WorldId == Guid.Empty);
 280
 281        if (parentId.HasValue)
 282        {
 283            query = query.Where(a => a.ParentId == parentId.Value);
 284        }
 285        else
 286        {
 287            query = query.Where(a => a.ParentId == null);
 288        }
 289
 290        if (excludeArticleId.HasValue)
 291        {
 292            query = query.Where(a => a.Id != excludeArticleId.Value);
 293        }
 294
 295        var existingSlugs = await query
 296            .Select(a => a.Slug)
 297            .ToHashSetAsync();
 298
 299        return SlugGenerator.GenerateUniqueSlug(baseSlug, existingSlugs);
 300    }
 301
 302    private static string NormalizeRequired(string? value, string paramName)
 303    {
 6304        if (string.IsNullOrWhiteSpace(value))
 305        {
 1306            throw new ArgumentException("Value is required.", paramName);
 307        }
 308
 5309        return value.Trim();
 310    }
 311}