< 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
0%
Covered lines: 0
Uncovered lines: 83
Coverable lines: 83
Total lines: 161
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 32
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%210%
IsPublicSlugAvailableAsync()0%620%
CheckPublicSlugAsync()0%2040%
GetWorldByPublicSlugAsync()0%110100%
ValidatePublicSlug(...)0%110100%
GenerateSuggestedSlug(...)0%620%
GenerateAvailableSlugAsync()0%2040%

File(s)

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

#LineLine coverage
 1using System.Text.RegularExpressions;
 2using Chronicis.Api.Data;
 3using Chronicis.Shared.DTOs;
 4using Microsoft.EntityFrameworkCore;
 5
 6namespace Chronicis.Api.Services;
 7
 8/// <summary>
 9/// Service for world public sharing and slug management
 10/// </summary>
 11public class WorldPublicSharingService : IWorldPublicSharingService
 12{
 13    private readonly ChronicisDbContext _context;
 14    private readonly ILogger<WorldPublicSharingService> _logger;
 15
 16    // Regex for valid public slug: lowercase alphanumeric with hyphens, no leading/trailing hyphens
 017    private static readonly Regex PublicSlugRegex = new(@"^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled);
 18
 19    // Reserved slugs that shouldn't be used
 020    private static readonly string[] ReservedSlugs =
 021        { "api", "admin", "public", "private", "new", "edit", "delete", "search", "login", "logout", "settings" };
 22
 023    public WorldPublicSharingService(ChronicisDbContext context, ILogger<WorldPublicSharingService> logger)
 24    {
 025        _context = context;
 026        _logger = logger;
 027    }
 28
 29    public async Task<bool> IsPublicSlugAvailableAsync(string publicSlug, Guid? excludeWorldId = null)
 30    {
 031        var normalizedSlug = publicSlug.Trim().ToLowerInvariant();
 32
 033        var query = _context.Worlds
 034            .AsNoTracking()
 035            .Where(w => w.PublicSlug == normalizedSlug);
 36
 037        if (excludeWorldId.HasValue)
 38        {
 039            query = query.Where(w => w.Id != excludeWorldId.Value);
 40        }
 41
 042        return !await query.AnyAsync();
 043    }
 44
 45    public async Task<PublicSlugCheckResultDto> CheckPublicSlugAsync(string slug, Guid? excludeWorldId = null)
 46    {
 047        var normalizedSlug = slug.Trim().ToLowerInvariant();
 48
 49        // Validate format first
 050        var validationError = ValidatePublicSlug(normalizedSlug);
 051        if (validationError != null)
 52        {
 053            return new PublicSlugCheckResultDto
 054            {
 055                IsAvailable = false,
 056                ValidationError = validationError,
 057                SuggestedSlug = GenerateSuggestedSlug(slug)
 058            };
 59        }
 60
 61        // Check availability
 062        var isAvailable = await IsPublicSlugAvailableAsync(normalizedSlug, excludeWorldId);
 63
 064        return new PublicSlugCheckResultDto
 065        {
 066            IsAvailable = isAvailable,
 067            ValidationError = null,
 068            SuggestedSlug = isAvailable ? null : await GenerateAvailableSlugAsync(normalizedSlug)
 069        };
 070    }
 71
 72    public async Task<WorldDto?> GetWorldByPublicSlugAsync(string publicSlug)
 73    {
 074        var normalizedSlug = publicSlug.Trim().ToLowerInvariant();
 75
 076        var world = await _context.Worlds
 077            .AsNoTracking()
 078            .Include(w => w.Owner)
 079            .Include(w => w.Campaigns)
 080            .FirstOrDefaultAsync(w => w.PublicSlug == normalizedSlug && w.IsPublic);
 81
 082        if (world == null)
 083            return null;
 84
 085        return new WorldDto
 086        {
 087            Id = world.Id,
 088            Name = world.Name,
 089            Slug = world.Slug,
 090            Description = world.Description,
 091            OwnerId = world.OwnerId,
 092            OwnerName = world.Owner?.DisplayName ?? "Unknown",
 093            CreatedAt = world.CreatedAt,
 094            CampaignCount = world.Campaigns?.Count ?? 0,
 095            MemberCount = world.Members?.Count ?? 0,
 096            IsPublic = world.IsPublic,
 097            PublicSlug = world.PublicSlug
 098        };
 099    }
 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    {
 0107        if (string.IsNullOrWhiteSpace(slug))
 0108            return "Public slug is required";
 109
 0110        if (slug.Length < 3)
 0111            return "Public slug must be at least 3 characters";
 112
 0113        if (slug.Length > 100)
 0114            return "Public slug must be 100 characters or less";
 115
 0116        if (!PublicSlugRegex.IsMatch(slug))
 0117            return "Public slug must contain only lowercase letters, numbers, and hyphens (no leading/trailing hyphens)"
 118
 0119        if (ReservedSlugs.Contains(slug))
 0120            return "This slug is reserved and cannot be used";
 121
 0122        return null;
 123    }
 124
 125    /// <summary>
 126    /// Generate a suggested slug from user input.
 127    /// </summary>
 128    private static string GenerateSuggestedSlug(string input)
 129    {
 0130        var slug = input.Trim().ToLowerInvariant();
 0131        slug = Regex.Replace(slug, @"[\s_]+", "-");
 0132        slug = Regex.Replace(slug, @"[^a-z0-9-]", "");
 0133        slug = Regex.Replace(slug, @"-+", "-");
 0134        slug = slug.Trim('-');
 135
 0136        if (slug.Length < 3)
 0137            slug = slug.PadRight(3, '0');
 138
 0139        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    {
 0147        var suffix = 1;
 0148        var candidate = $"{baseSlug}-{suffix}";
 149
 0150        while (!await IsPublicSlugAvailableAsync(candidate))
 151        {
 0152            suffix++;
 0153            candidate = $"{baseSlug}-{suffix}";
 154
 0155            if (suffix > 100)
 0156                return $"{baseSlug}-{Guid.NewGuid().ToString()[..8]}";
 157        }
 158
 0159        return candidate;
 0160    }
 161}