< Summary

Information
Class: Chronicis.Api.Services.WorldService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/WorldService.cs
Line coverage
87%
Covered lines: 228
Uncovered lines: 32
Coverable lines: 260
Total lines: 376
Line coverage: 87.6%
Branch coverage
53%
Covered branches: 34
Total branches: 64
Branch coverage: 53.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetUserWorldsAsync()100%11100%
GetWorldAsync()100%44100%
CreateWorldAsync()100%22100%
UpdateWorldAsync()43.75%561645.94%
GetWorldBySlugAsync()100%22100%
GenerateUniqueWorldSlugAsync()100%22100%
MapToDto(...)50%88100%
MapToDetailDto(...)43.33%613067.56%

File(s)

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

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Shared.DTOs;
 3using Chronicis.Shared.Enums;
 4using Chronicis.Shared.Extensions;
 5using Chronicis.Shared.Models;
 6using Chronicis.Shared.Utilities;
 7using Microsoft.EntityFrameworkCore;
 8
 9namespace Chronicis.Api.Services;
 10
 11/// <summary>
 12/// Service for core world management (CRUD, lookup, creation)
 13/// </summary>
 14public class WorldService : IWorldService
 15{
 16    private readonly ChronicisDbContext _context;
 17    private readonly IWorldMembershipService _membershipService;
 18    private readonly IWorldPublicSharingService _publicSharingService;
 19    private readonly ILogger<WorldService> _logger;
 20
 1721    public WorldService(
 1722        ChronicisDbContext context,
 1723        IWorldMembershipService membershipService,
 1724        IWorldPublicSharingService publicSharingService,
 1725        ILogger<WorldService> logger)
 26    {
 1727        _context = context;
 1728        _membershipService = membershipService;
 1729        _publicSharingService = publicSharingService;
 1730        _logger = logger;
 1731    }
 32
 33    public async Task<List<WorldDto>> GetUserWorldsAsync(Guid userId)
 34    {
 335        var worlds = await _context.Worlds
 336            .Where(w => w.Members.Any(m => m.UserId == userId))
 337            .Include(w => w.Owner)
 338            .Include(w => w.Campaigns)
 339            .Include(w => w.Members)
 340            .ToListAsync();
 41
 342        return worlds.Select(MapToDto).ToList();
 343    }
 44
 45    public async Task<WorldDetailDto?> GetWorldAsync(Guid worldId, Guid userId)
 46    {
 347        var world = await _context.Worlds
 348            .Include(w => w.Owner)
 349            .Include(w => w.Campaigns)
 350                .ThenInclude(c => c.Owner)
 351            .Include(w => w.Members)
 352                .ThenInclude(m => m.User)
 353            .FirstOrDefaultAsync(w => w.Id == worldId);
 54
 355        if (world == null)
 156            return null;
 57
 58        // Check access via membership service
 259        if (!await _membershipService.UserHasAccessAsync(worldId, userId))
 160            return null;
 61
 162        return MapToDetailDto(world);
 363    }
 64
 65    public async Task<WorldDto> CreateWorldAsync(WorldCreateDto dto, Guid userId)
 66    {
 667        var user = await _context.Users.FindAsync(userId);
 668        if (user == null)
 169            throw new InvalidOperationException("User not found");
 70
 571        _logger.LogDebugSanitized("Creating world '{Name}' for user {UserId}", dto.Name, userId);
 72
 573        var now = DateTime.UtcNow;
 74
 75        // Generate unique slug for this owner
 576        var slug = await GenerateUniqueWorldSlugAsync(dto.Name, userId);
 77
 78        // Create the World entity
 579        var world = new World
 580        {
 581            Id = Guid.NewGuid(),
 582            Name = dto.Name,
 583            Slug = slug,
 584            Description = dto.Description,
 585            OwnerId = userId,
 586            CreatedAt = now,
 587            IsPublic = false,
 588            PublicSlug = null
 589        };
 590        _context.Worlds.Add(world);
 91
 92        // Create default Wiki articles
 593        var wikiArticles = new[]
 594        {
 595            new Article
 596            {
 597                Id = Guid.NewGuid(),
 598                Title = "Bestiary",
 599                Slug = "bestiary",
 5100                Body = "# Bestiary\n\nA collection of creatures and monsters encountered in your adventures.",
 5101                Type = ArticleType.WikiArticle,
 5102                Visibility = ArticleVisibility.Public,
 5103                WorldId = world.Id,
 5104                CreatedBy = userId,
 5105                CreatedAt = now,
 5106                EffectiveDate = now,
 5107                IconEmoji = "🐉"
 5108            },
 5109            new Article
 5110            {
 5111                Id = Guid.NewGuid(),
 5112                Title = "Characters",
 5113                Slug = "characters",
 5114                Body = "# Characters\n\nNPCs and notable figures in your world.",
 5115                Type = ArticleType.WikiArticle,
 5116                Visibility = ArticleVisibility.Public,
 5117                WorldId = world.Id,
 5118                CreatedBy = userId,
 5119                CreatedAt = now,
 5120                EffectiveDate = now,
 5121                IconEmoji = "👤"
 5122            },
 5123            new Article
 5124            {
 5125                Id = Guid.NewGuid(),
 5126                Title = "Factions",
 5127                Slug = "factions",
 5128                Body = "# Factions\n\nOrganizations, guilds, and groups that shape your world.",
 5129                Type = ArticleType.WikiArticle,
 5130                Visibility = ArticleVisibility.Public,
 5131                WorldId = world.Id,
 5132                CreatedBy = userId,
 5133                CreatedAt = now,
 5134                EffectiveDate = now,
 5135                IconEmoji = "⚔️"
 5136            },
 5137            new Article
 5138            {
 5139                Id = Guid.NewGuid(),
 5140                Title = "Locations",
 5141                Slug = "locations",
 5142                Body = "# Locations\n\nPlaces of interest, cities, dungeons, and landmarks.",
 5143                Type = ArticleType.WikiArticle,
 5144                Visibility = ArticleVisibility.Public,
 5145                WorldId = world.Id,
 5146                CreatedBy = userId,
 5147                CreatedAt = now,
 5148                EffectiveDate = now,
 5149                IconEmoji = "🗺️"
 5150            }
 5151        };
 5152        _context.Articles.AddRange(wikiArticles);
 153
 154        // Create default Player Character
 5155        var newCharacter = new Article
 5156        {
 5157            Id = Guid.NewGuid(),
 5158            Title = "New Character",
 5159            Slug = "new-character",
 5160            Body = "# New Character\n\nDescribe your character here. Add their backstory, personality, and goals.",
 5161            Type = ArticleType.Character,
 5162            Visibility = ArticleVisibility.Public,
 5163            WorldId = world.Id,
 5164            CreatedBy = userId,
 5165            PlayerId = userId,
 5166            CreatedAt = now,
 5167            EffectiveDate = now,
 5168            IconEmoji = "🧙"
 5169        };
 5170        _context.Articles.Add(newCharacter);
 171
 172        // Create default Campaign
 5173        var campaign = new Campaign
 5174        {
 5175            Id = Guid.NewGuid(),
 5176            Name = "Campaign 1",
 5177            Description = "Your first campaign adventure begins here.",
 5178            WorldId = world.Id,
 5179            OwnerId = userId,
 5180            CreatedAt = now
 5181        };
 5182        _context.Campaigns.Add(campaign);
 183
 184        // Create default Arc under the campaign
 5185        var arc = new Arc
 5186        {
 5187            Id = Guid.NewGuid(),
 5188            Name = "Arc 1",
 5189            Description = "The first chapter of your adventure.",
 5190            CampaignId = campaign.Id,
 5191            SortOrder = 1,
 5192            CreatedBy = userId,
 5193            CreatedAt = now
 5194        };
 5195        _context.Arcs.Add(arc);
 196
 5197        await _context.SaveChangesAsync();
 198
 5199        _logger.LogDebug("Created world {WorldId} with default content for user {UserId}", world.Id, userId);
 200
 5201        world.Owner = user;
 5202        return MapToDto(world);
 5203    }
 204
 205    public async Task<WorldDto?> UpdateWorldAsync(Guid worldId, WorldUpdateDto dto, Guid userId)
 206    {
 3207        var world = await _context.Worlds
 3208            .Include(w => w.Owner)
 3209            .Include(w => w.Campaigns)
 3210            .FirstOrDefaultAsync(w => w.Id == worldId);
 211
 3212        if (world == null)
 1213            return null;
 214
 215        // Only owner can update
 2216        if (world.OwnerId != userId)
 1217            return null;
 218
 219        // If name changed, regenerate slug
 1220        if (world.Name != dto.Name)
 221        {
 1222            world.Slug = await GenerateUniqueWorldSlugAsync(dto.Name, userId, world.Id);
 223        }
 224
 1225        world.Name = dto.Name;
 1226        world.Description = dto.Description;
 227
 228        // Handle public visibility changes if specified
 1229        if (dto.IsPublic.HasValue)
 230        {
 0231            if (dto.IsPublic.Value)
 232            {
 233                // Making world public - require a valid public slug
 0234                if (string.IsNullOrWhiteSpace(dto.PublicSlug))
 235                {
 0236                    _logger.LogWarning("Attempted to make world {WorldId} public without a public slug", worldId);
 0237                    return null;
 238                }
 239
 0240                var normalizedSlug = dto.PublicSlug.Trim().ToLowerInvariant();
 241
 242                // Validate slug format via public sharing service
 0243                var validationError = _publicSharingService.ValidatePublicSlug(normalizedSlug);
 0244                if (validationError != null)
 245                {
 0246                    _logger.LogWarningSanitized("Invalid public slug '{Slug}' for world {WorldId}: {Error}",
 0247                        normalizedSlug, worldId, validationError);
 0248                    return null;
 249                }
 250
 251                // Check availability via public sharing service
 0252                if (!await _publicSharingService.IsPublicSlugAvailableAsync(normalizedSlug, worldId))
 253                {
 0254                    _logger.LogWarningSanitized("Public slug '{Slug}' is already taken", normalizedSlug);
 0255                    return null;
 256                }
 257
 0258                world.IsPublic = true;
 0259                world.PublicSlug = normalizedSlug;
 260
 0261                _logger.LogDebugSanitized("World {WorldId} is now public with slug '{PublicSlug}'", worldId, normalizedS
 0262            }
 263            else
 264            {
 265                // Making world private - clear public slug
 0266                world.IsPublic = false;
 0267                world.PublicSlug = null;
 268
 0269                _logger.LogDebug("World {WorldId} is now private", worldId);
 270            }
 271        }
 272
 1273        await _context.SaveChangesAsync();
 274
 1275        _logger.LogDebug("Updated world {WorldId}", worldId);
 276
 1277        return MapToDto(world);
 3278    }
 279
 280    public async Task<WorldDto?> GetWorldBySlugAsync(string slug, Guid userId)
 281    {
 3282        var world = await _context.Worlds
 3283            .AsNoTracking()
 3284            .Include(w => w.Owner)
 3285            .Include(w => w.Campaigns)
 3286            .Include(w => w.Members)
 3287            .FirstOrDefaultAsync(w => w.Slug == slug && w.Members.Any(m => m.UserId == userId));
 288
 3289        if (world == null)
 2290            return null;
 291
 1292        return MapToDto(world);
 3293    }
 294
 295    /// <summary>
 296    /// Generate a unique slug for a world within an owner's worlds.
 297    /// </summary>
 298    private async Task<string> GenerateUniqueWorldSlugAsync(string name, Guid ownerId, Guid? excludeWorldId = null)
 299    {
 6300        var baseSlug = SlugGenerator.GenerateSlug(name);
 301
 6302        var existingSlugsQuery = _context.Worlds
 6303            .AsNoTracking()
 6304            .Where(w => w.OwnerId == ownerId);
 305
 6306        if (excludeWorldId.HasValue)
 307        {
 1308            existingSlugsQuery = existingSlugsQuery.Where(w => w.Id != excludeWorldId.Value);
 309        }
 310
 6311        var existingSlugs = await existingSlugsQuery
 6312            .Select(w => w.Slug)
 6313            .ToHashSetAsync();
 314
 6315        return SlugGenerator.GenerateUniqueSlug(baseSlug, existingSlugs);
 6316    }
 317
 318    private static WorldDto MapToDto(World world)
 319    {
 10320        return new WorldDto
 10321        {
 10322            Id = world.Id,
 10323            Name = world.Name,
 10324            Slug = world.Slug,
 10325            Description = world.Description,
 10326            OwnerId = world.OwnerId,
 10327            OwnerName = world.Owner?.DisplayName ?? "Unknown",
 10328            CreatedAt = world.CreatedAt,
 10329            CampaignCount = world.Campaigns?.Count ?? 0,
 10330            MemberCount = world.Members?.Count ?? 0,
 10331            IsPublic = world.IsPublic,
 10332            PublicSlug = world.PublicSlug
 10333        };
 334    }
 335
 336    private static WorldDetailDto MapToDetailDto(World world)
 337    {
 1338        return new WorldDetailDto
 1339        {
 1340            Id = world.Id,
 1341            Name = world.Name,
 1342            Slug = world.Slug,
 1343            Description = world.Description,
 1344            OwnerId = world.OwnerId,
 1345            OwnerName = world.Owner?.DisplayName ?? "Unknown",
 1346            CreatedAt = world.CreatedAt,
 1347            CampaignCount = world.Campaigns?.Count ?? 0,
 1348            MemberCount = world.Members?.Count ?? 0,
 1349            IsPublic = world.IsPublic,
 1350            PublicSlug = world.PublicSlug,
 0351            Campaigns = world.Campaigns?.Select(c => new CampaignDto
 0352            {
 0353                Id = c.Id,
 0354                WorldId = c.WorldId,
 0355                Name = c.Name,
 0356                Description = c.Description,
 0357                OwnerId = c.OwnerId,
 0358                OwnerName = c.Owner?.DisplayName ?? "Unknown",
 0359                CreatedAt = c.CreatedAt,
 0360                StartedAt = c.StartedAt,
 0361                EndedAt = c.EndedAt
 0362            }).ToList() ?? new List<CampaignDto>(),
 2363            Members = world.Members?.Select(m => new WorldMemberDto
 2364            {
 2365                Id = m.Id,
 2366                UserId = m.UserId,
 2367                DisplayName = m.User?.DisplayName ?? "Unknown",
 2368                Email = m.User?.Email ?? "",
 2369                AvatarUrl = m.User?.AvatarUrl,
 2370                Role = m.Role,
 2371                JoinedAt = m.JoinedAt,
 2372                InvitedBy = m.InvitedBy
 2373            }).ToList() ?? new List<WorldMemberDto>()
 1374        };
 375    }
 376}