< Summary

Information
Class: Chronicis.Api.Services.WorldPublicSharingService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/WorldPublicSharingService.cs
Line coverage
100%
Covered lines: 25
Uncovered lines: 0
Coverable lines: 25
Total lines: 164
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
.cctor()100%11100%
.ctor(...)100%11100%
ValidatePublicSlug(...)100%1010100%
GenerateSuggestedSlug(...)100%22100%

File(s)

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

#LineLine coverage
 1using System.Collections.Frozen;
 2using System.Text.RegularExpressions;
 3using Chronicis.Api.Data;
 4using Chronicis.Shared.DTOs;
 5using Microsoft.EntityFrameworkCore;
 6
 7namespace Chronicis.Api.Services;
 8
 9/// <summary>
 10/// Service for world public sharing and slug management
 11/// </summary>
 12public sealed partial class WorldPublicSharingService : IWorldPublicSharingService
 13{
 14    private readonly ChronicisDbContext _context;
 15    private readonly ILogger<WorldPublicSharingService> _logger;
 16
 17    // Reserved slugs that shouldn't be used
 118    private static readonly FrozenSet<string> ReservedSlugs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
 119        { "api", "admin", "public", "private", "new", "edit", "delete", "search", "login", "logout", "settings" }
 120        .ToFrozenSet(StringComparer.OrdinalIgnoreCase);
 21
 22    public WorldPublicSharingService(ChronicisDbContext context, ILogger<WorldPublicSharingService> logger)
 23    {
 124        _context = context;
 125        _logger = logger;
 126    }
 27
 28    public async Task<bool> IsPublicSlugAvailableAsync(string publicSlug, Guid? excludeWorldId = null)
 29    {
 30        var normalizedSlug = publicSlug.Trim().ToLowerInvariant();
 31
 32        var query = _context.Worlds
 33            .AsNoTracking()
 34            .Where(w => w.PublicSlug == normalizedSlug);
 35
 36        if (excludeWorldId.HasValue)
 37        {
 38            query = query.Where(w => w.Id != excludeWorldId.Value);
 39        }
 40
 41        return !await query.AnyAsync();
 42    }
 43
 44    public async Task<PublicSlugCheckResultDto> CheckPublicSlugAsync(string slug, Guid? excludeWorldId = null)
 45    {
 46        var normalizedSlug = slug.Trim().ToLowerInvariant();
 47
 48        // Validate format first
 49        var validationError = ValidatePublicSlug(normalizedSlug);
 50        if (validationError != null)
 51        {
 52            return new PublicSlugCheckResultDto
 53            {
 54                IsAvailable = false,
 55                ValidationError = validationError,
 56                SuggestedSlug = GenerateSuggestedSlug(slug)
 57            };
 58        }
 59
 60        // Check availability
 61        var isAvailable = await IsPublicSlugAvailableAsync(normalizedSlug, excludeWorldId);
 62
 63        return new PublicSlugCheckResultDto
 64        {
 65            IsAvailable = isAvailable,
 66            ValidationError = null,
 67            SuggestedSlug = isAvailable ? null : await GenerateAvailableSlugAsync(normalizedSlug)
 68        };
 69    }
 70
 71    public async Task<WorldDto?> GetWorldByPublicSlugAsync(string publicSlug)
 72    {
 73        var normalizedSlug = publicSlug.Trim().ToLowerInvariant();
 74
 75        var world = await _context.Worlds
 76            .AsNoTracking()
 77            .Include(w => w.Owner)
 78            .Include(w => w.Campaigns)
 79            .FirstOrDefaultAsync(w => w.PublicSlug == normalizedSlug && w.IsPublic);
 80
 81        if (world == null)
 82            return null;
 83
 84        return new WorldDto
 85        {
 86            Id = world.Id,
 87            Name = world.Name,
 88            Slug = world.Slug,
 89            Description = world.Description,
 90            OwnerId = world.OwnerId,
 91            OwnerName = world.Owner?.DisplayName ?? "Unknown",
 92            CreatedAt = world.CreatedAt,
 93            CampaignCount = world.Campaigns?.Count ?? 0,
 94            MemberCount = world.Members?.Count ?? 0,
 95            IsPublic = world.IsPublic,
 96            IsTutorial = world.IsTutorial,
 97            PublicSlug = world.PublicSlug
 98        };
 99    }
 100
 101    /// <summary>
 102    /// Validate a public slug format.
 103    /// Returns null if valid, or an error message if invalid.
 104    /// </summary>
 105    public string? ValidatePublicSlug(string slug)
 106    {
 6107        if (string.IsNullOrWhiteSpace(slug))
 1108            return "Public slug is required";
 109
 5110        if (slug.Length < 3)
 1111            return "Public slug must be at least 3 characters";
 112
 4113        if (slug.Length > 100)
 1114            return "Public slug must be 100 characters or less";
 115
 3116        if (!ValidPublicSlugRegex().IsMatch(slug))
 1117            return "Public slug must contain only lowercase letters, numbers, and hyphens (no leading/trailing hyphens)"
 118
 2119        if (ReservedSlugs.Contains(slug))
 1120            return "This slug is reserved and cannot be used";
 121
 1122        return null;
 123    }
 124
 125    /// <summary>
 126    /// Generate a suggested slug from user input.
 127    /// </summary>
 128    private static string GenerateSuggestedSlug(string input)
 129    {
 1130        var slug = input.Trim().ToLowerInvariant();
 1131        slug = Regex.Replace(slug, @"[\s_]+", "-");
 1132        slug = Regex.Replace(slug, @"[^a-z0-9-]", "");
 1133        slug = Regex.Replace(slug, @"-+", "-");
 1134        slug = slug.Trim('-');
 135
 1136        if (slug.Length < 3)
 1137            slug = slug.PadRight(3, '0');
 138
 1139        return slug;
 140    }
 141
 142    /// <summary>
 143    /// Generate an available slug by appending numbers.
 144    /// </summary>
 145    private async Task<string> GenerateAvailableSlugAsync(string baseSlug)
 146    {
 147        var suffix = 1;
 148        var candidate = $"{baseSlug}-{suffix}";
 149
 150        while (!await IsPublicSlugAvailableAsync(candidate))
 151        {
 152            suffix++;
 153            candidate = $"{baseSlug}-{suffix}";
 154
 155            if (suffix > 100)
 156                return $"{baseSlug}-{Guid.NewGuid().ToString()[..8]}";
 157        }
 158
 159        return candidate;
 160    }
 161
 162    [GeneratedRegex(@"^[a-z0-9]+(-[a-z0-9]+)*$")]
 163    private static partial Regex ValidPublicSlugRegex();
 164}