< Summary

Information
Class: Chronicis.Api.Services.QuestService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/QuestService.cs
Line coverage
69%
Covered lines: 163
Uncovered lines: 71
Coverable lines: 234
Total lines: 408
Line coverage: 69.6%
Branch coverage
75%
Covered branches: 45
Total branches: 60
Branch coverage: 75%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetQuestsByArcAsync()100%66100%
GetQuestAsync()87.5%8897.22%
CreateQuestAsync()50%821429.62%
UpdateQuestAsync()76.92%642661.72%
DeleteQuestAsync()83.33%6694.44%

File(s)

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

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Api.Models;
 3using Chronicis.Shared.DTOs.Quests;
 4using Chronicis.Shared.Enums;
 5using Chronicis.Shared.Extensions;
 6using Chronicis.Shared.Models;
 7using Microsoft.EntityFrameworkCore;
 8
 9namespace Chronicis.Api.Services;
 10
 11/// <summary>
 12/// Service for Quest operations with world membership authorization.
 13/// </summary>
 14public class QuestService : IQuestService
 15{
 16    private readonly ChronicisDbContext _context;
 17    private readonly ILogger<QuestService> _logger;
 18
 1819    public QuestService(ChronicisDbContext context, ILogger<QuestService> logger)
 20    {
 1821        _context = context;
 1822        _logger = logger;
 1823    }
 24
 25    public async Task<ServiceResult<List<QuestDto>>> GetQuestsByArcAsync(Guid arcId, Guid userId)
 26    {
 27        // Check if user has access to this arc via world membership
 428        var arc = await _context.Arcs
 429            .AsNoTracking()
 430            .Include(a => a.Campaign)
 431            .FirstOrDefaultAsync(a => a.Id == arcId);
 32
 433        if (arc == null)
 34        {
 135            return ServiceResult<List<QuestDto>>.NotFound("Arc not found");
 36        }
 37
 38        // Check world membership
 339        var member = await _context.WorldMembers
 340            .AsNoTracking()
 341            .FirstOrDefaultAsync(m => m.WorldId == arc.Campaign.WorldId && m.UserId == userId);
 42
 343        if (member == null)
 44        {
 45            // Don't disclose arc existence to non-members
 146            return ServiceResult<List<QuestDto>>.NotFound("Arc not found");
 47        }
 48
 249        var isGM = member.Role == WorldRole.GM;
 50
 51        // Build query - filter IsGmOnly for non-GM users
 252        var query = _context.Quests
 253            .AsNoTracking()
 254            .Where(q => q.ArcId == arcId);
 55
 256        if (!isGM)
 57        {
 158            query = query.Where(q => !q.IsGmOnly);
 59        }
 60
 261        var quests = await query
 262            .OrderBy(q => q.SortOrder)
 263            .ThenByDescending(q => q.UpdatedAt)
 264            .Select(q => new QuestDto
 265            {
 266                Id = q.Id,
 267                ArcId = q.ArcId,
 268                Title = q.Title,
 269                Description = q.Description,
 270                Status = q.Status,
 271                IsGmOnly = q.IsGmOnly,
 272                SortOrder = q.SortOrder,
 273                CreatedBy = q.CreatedBy,
 274                CreatedByName = q.Creator.DisplayName,
 275                CreatedAt = q.CreatedAt,
 276                UpdatedAt = q.UpdatedAt,
 277                RowVersion = Convert.ToBase64String(q.RowVersion),
 278                UpdateCount = q.Updates.Count
 279            })
 280            .ToListAsync();
 81
 282        _logger.LogDebug("Retrieved {Count} quests for arc {ArcId} for user {UserId} (GM: {IsGM})",
 283            quests.Count, arcId, userId, isGM);
 84
 285        return ServiceResult<List<QuestDto>>.Success(quests);
 486    }
 87
 88    public async Task<ServiceResult<QuestDto>> GetQuestAsync(Guid questId, Guid userId)
 89    {
 490        var quest = await _context.Quests
 491            .AsNoTracking()
 492            .Include(q => q.Arc)
 493                .ThenInclude(a => a.Campaign)
 494            .Include(q => q.Creator)
 495            .FirstOrDefaultAsync(q => q.Id == questId);
 96
 497        if (quest == null)
 98        {
 099            return ServiceResult<QuestDto>.NotFound("Quest not found");
 100        }
 101
 102        // Check world membership
 4103        var member = await _context.WorldMembers
 4104            .AsNoTracking()
 4105            .FirstOrDefaultAsync(m => m.WorldId == quest.Arc.Campaign.WorldId && m.UserId == userId);
 106
 4107        if (member == null)
 108        {
 109            // Don't disclose quest existence to non-members
 1110            return ServiceResult<QuestDto>.NotFound("Quest not found");
 111        }
 112
 3113        var isGM = member.Role == WorldRole.GM;
 114
 115        // Non-GM users cannot see GM-only quests
 3116        if (quest.IsGmOnly && !isGM)
 117        {
 1118            return ServiceResult<QuestDto>.NotFound("Quest not found");
 119        }
 120
 2121        var updateCount = await _context.QuestUpdates
 2122            .CountAsync(qu => qu.QuestId == questId);
 123
 2124        var dto = new QuestDto
 2125        {
 2126            Id = quest.Id,
 2127            ArcId = quest.ArcId,
 2128            Title = quest.Title,
 2129            Description = quest.Description,
 2130            Status = quest.Status,
 2131            IsGmOnly = quest.IsGmOnly,
 2132            SortOrder = quest.SortOrder,
 2133            CreatedBy = quest.CreatedBy,
 2134            CreatedByName = quest.Creator.DisplayName,
 2135            CreatedAt = quest.CreatedAt,
 2136            UpdatedAt = quest.UpdatedAt,
 2137            RowVersion = Convert.ToBase64String(quest.RowVersion),
 2138            UpdateCount = updateCount
 2139        };
 140
 2141        return ServiceResult<QuestDto>.Success(dto);
 4142    }
 143
 144    public async Task<ServiceResult<QuestDto>> CreateQuestAsync(Guid arcId, QuestCreateDto dto, Guid userId)
 145    {
 146        // Validate input
 3147        if (string.IsNullOrWhiteSpace(dto.Title))
 148        {
 1149            return ServiceResult<QuestDto>.ValidationError("Title is required");
 150        }
 151
 2152        if (dto.Title.Length > 300)
 153        {
 1154            return ServiceResult<QuestDto>.ValidationError("Title cannot exceed 300 characters");
 155        }
 156
 157        // Check if user has access to this arc and is GM
 1158        var arc = await _context.Arcs
 1159            .AsNoTracking()
 1160            .Include(a => a.Campaign)
 1161            .FirstOrDefaultAsync(a => a.Id == arcId);
 162
 1163        if (arc == null)
 164        {
 0165            return ServiceResult<QuestDto>.NotFound("Arc not found");
 166        }
 167
 168        // Check world membership and GM role
 1169        var member = await _context.WorldMembers
 1170            .AsNoTracking()
 1171            .FirstOrDefaultAsync(m => m.WorldId == arc.Campaign.WorldId && m.UserId == userId);
 172
 1173        if (member == null)
 174        {
 0175            return ServiceResult<QuestDto>.NotFound("Arc not found");
 176        }
 177
 1178        if (member.Role != WorldRole.GM)
 179        {
 1180            return ServiceResult<QuestDto>.Forbidden("Only GMs can create quests");
 181        }
 182
 0183        var now = DateTime.UtcNow;
 0184        var quest = new Quest
 0185        {
 0186            Id = Guid.NewGuid(),
 0187            ArcId = arcId,
 0188            Title = dto.Title.Trim(),
 0189            Description = dto.Description,
 0190            Status = dto.Status ?? QuestStatus.Active,
 0191            IsGmOnly = dto.IsGmOnly ?? false,
 0192            SortOrder = dto.SortOrder ?? 0,
 0193            CreatedBy = userId,
 0194            CreatedAt = now,
 0195            UpdatedAt = now
 0196        };
 197
 0198        _context.Quests.Add(quest);
 0199        await _context.SaveChangesAsync();
 200
 0201        _logger.LogDebugSanitized("Created quest '{Title}' in arc {ArcId} for user {UserId}",
 0202            dto.Title, arcId, userId);
 203
 204        // Fetch creator name for DTO
 0205        var creator = await _context.Users.FindAsync(userId);
 206
 0207        var resultDto = new QuestDto
 0208        {
 0209            Id = quest.Id,
 0210            ArcId = quest.ArcId,
 0211            Title = quest.Title,
 0212            Description = quest.Description,
 0213            Status = quest.Status,
 0214            IsGmOnly = quest.IsGmOnly,
 0215            SortOrder = quest.SortOrder,
 0216            CreatedBy = quest.CreatedBy,
 0217            CreatedByName = creator?.DisplayName ?? "Unknown",
 0218            CreatedAt = quest.CreatedAt,
 0219            UpdatedAt = quest.UpdatedAt,
 0220            RowVersion = Convert.ToBase64String(quest.RowVersion),
 0221            UpdateCount = 0
 0222        };
 223
 0224        return ServiceResult<QuestDto>.Success(resultDto);
 3225    }
 226
 227    public async Task<ServiceResult<QuestDto>> UpdateQuestAsync(Guid questId, QuestEditDto dto, Guid userId)
 228    {
 229        // Validate RowVersion is provided
 3230        if (string.IsNullOrWhiteSpace(dto.RowVersion))
 231        {
 1232            return ServiceResult<QuestDto>.ValidationError("RowVersion is required for updates");
 233        }
 234
 235        byte[] rowVersion;
 236        try
 237        {
 2238            rowVersion = Convert.FromBase64String(dto.RowVersion);
 2239        }
 0240        catch (FormatException)
 241        {
 0242            return ServiceResult<QuestDto>.ValidationError("Invalid RowVersion format");
 243        }
 244
 245        // Validate title if provided
 2246        if (dto.Title != null && string.IsNullOrWhiteSpace(dto.Title))
 247        {
 0248            return ServiceResult<QuestDto>.ValidationError("Title cannot be empty");
 249        }
 250
 2251        if (dto.Title != null && dto.Title.Length > 300)
 252        {
 0253            return ServiceResult<QuestDto>.ValidationError("Title cannot exceed 300 characters");
 254        }
 255
 256        // Find quest with tracking for update
 2257        var quest = await _context.Quests
 2258            .Include(q => q.Arc)
 2259                .ThenInclude(a => a.Campaign)
 2260            .Include(q => q.Creator)
 2261            .FirstOrDefaultAsync(q => q.Id == questId);
 262
 2263        if (quest == null)
 264        {
 0265            return ServiceResult<QuestDto>.NotFound("Quest not found");
 266        }
 267
 268        // Check world membership and GM role
 2269        var member = await _context.WorldMembers
 2270            .AsNoTracking()
 2271            .FirstOrDefaultAsync(m => m.WorldId == quest.Arc.Campaign.WorldId && m.UserId == userId);
 272
 2273        if (member == null)
 274        {
 0275            return ServiceResult<QuestDto>.NotFound("Quest not found");
 276        }
 277
 2278        if (member.Role != WorldRole.GM)
 279        {
 1280            return ServiceResult<QuestDto>.Forbidden("Only GMs can update quests");
 281        }
 282
 283        // Apply updates
 1284        if (dto.Title != null)
 285        {
 1286            quest.Title = dto.Title.Trim();
 287        }
 288
 1289        if (dto.Description != null)
 290        {
 1291            quest.Description = dto.Description;
 292        }
 293
 1294        if (dto.Status.HasValue)
 295        {
 1296            quest.Status = dto.Status.Value;
 297        }
 298
 1299        if (dto.IsGmOnly.HasValue)
 300        {
 0301            quest.IsGmOnly = dto.IsGmOnly.Value;
 302        }
 303
 1304        if (dto.SortOrder.HasValue)
 305        {
 0306            quest.SortOrder = dto.SortOrder.Value;
 307        }
 308
 1309        quest.UpdatedAt = DateTime.UtcNow;
 310
 311        // Set original RowVersion for concurrency check
 1312        _context.Entry(quest).Property(q => q.RowVersion).OriginalValue = rowVersion;
 313
 314        try
 315        {
 1316            await _context.SaveChangesAsync();
 317
 1318            _logger.LogDebug("Updated quest {QuestId} for user {UserId}", questId, userId);
 1319        }
 0320        catch (DbUpdateConcurrencyException)
 321        {
 322            // Reload current state from database
 0323            await _context.Entry(quest).ReloadAsync();
 324
 0325            var updateCount = await _context.QuestUpdates.CountAsync(qu => qu.QuestId == questId);
 326
 0327            var currentDto = new QuestDto
 0328            {
 0329                Id = quest.Id,
 0330                ArcId = quest.ArcId,
 0331                Title = quest.Title,
 0332                Description = quest.Description,
 0333                Status = quest.Status,
 0334                IsGmOnly = quest.IsGmOnly,
 0335                SortOrder = quest.SortOrder,
 0336                CreatedBy = quest.CreatedBy,
 0337                CreatedByName = quest.Creator.DisplayName,
 0338                CreatedAt = quest.CreatedAt,
 0339                UpdatedAt = quest.UpdatedAt,
 0340                RowVersion = Convert.ToBase64String(quest.RowVersion),
 0341                UpdateCount = updateCount
 0342            };
 343
 0344            _logger.LogWarning("Concurrency conflict updating quest {QuestId}", questId);
 345
 0346            return ServiceResult<QuestDto>.Conflict(
 0347                "Quest was modified by another user. Please reload and try again.",
 0348                currentDto);
 349        }
 350
 351        // Fetch update count for result
 1352        var resultUpdateCount = await _context.QuestUpdates.CountAsync(qu => qu.QuestId == questId);
 353
 1354        var resultDto = new QuestDto
 1355        {
 1356            Id = quest.Id,
 1357            ArcId = quest.ArcId,
 1358            Title = quest.Title,
 1359            Description = quest.Description,
 1360            Status = quest.Status,
 1361            IsGmOnly = quest.IsGmOnly,
 1362            SortOrder = quest.SortOrder,
 1363            CreatedBy = quest.CreatedBy,
 1364            CreatedByName = quest.Creator.DisplayName,
 1365            CreatedAt = quest.CreatedAt,
 1366            UpdatedAt = quest.UpdatedAt,
 1367            RowVersion = Convert.ToBase64String(quest.RowVersion),
 1368            UpdateCount = resultUpdateCount
 1369        };
 370
 1371        return ServiceResult<QuestDto>.Success(resultDto);
 3372    }
 373
 374    public async Task<ServiceResult<bool>> DeleteQuestAsync(Guid questId, Guid userId)
 375    {
 4376        var quest = await _context.Quests
 4377            .Include(q => q.Arc)
 4378                .ThenInclude(a => a.Campaign)
 4379            .FirstOrDefaultAsync(q => q.Id == questId);
 380
 4381        if (quest == null)
 382        {
 1383            return ServiceResult<bool>.NotFound("Quest not found");
 384        }
 385
 386        // Check world membership and GM role
 3387        var member = await _context.WorldMembers
 3388            .AsNoTracking()
 3389            .FirstOrDefaultAsync(m => m.WorldId == quest.Arc.Campaign.WorldId && m.UserId == userId);
 390
 3391        if (member == null)
 392        {
 0393            return ServiceResult<bool>.NotFound("Quest not found");
 394        }
 395
 3396        if (member.Role != WorldRole.GM)
 397        {
 1398            return ServiceResult<bool>.Forbidden("Only GMs can delete quests");
 399        }
 400
 2401        _context.Quests.Remove(quest);
 2402        await _context.SaveChangesAsync();
 403
 2404        _logger.LogDebug("Deleted quest {QuestId} and its updates for user {UserId}", questId, userId);
 405
 2406        return ServiceResult<bool>.Success(true);
 4407    }
 408}