diff --git a/SVSim.EmulatedEntrypoint/Controllers/CampaignController.cs b/SVSim.EmulatedEntrypoint/Controllers/CampaignController.cs new file mode 100644 index 0000000..bd19353 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/CampaignController.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.EmulatedEntrypoint.Models.Dtos.Campaign; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// /campaign/* — promotional surfaces. Currently just regist_serial_code. +/// +[Route("campaign")] +public sealed class CampaignController : SVSimController +{ + private const int FailureResultCode = 4202; + + private readonly SVSimDbContext _db; + + public CampaignController(SVSimDbContext db) => _db = db; + + [HttpPost("regist_serial_code")] + public async Task RegisterSerialCode( + [FromBody] RegisterSerialCodeRequest request, + CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + + var now = DateTime.UtcNow; + + var code = await _db.SerialCodes + .Include(c => c.Rewards) + .FirstOrDefaultAsync(c => c.Code == request.SerialCode, ct); + + if (code is null) return Fail(); + if (!code.IsEnabled) return Fail(); + if (code.StartAt is { } start && start > now) return Fail(); + if (code.EndAt is { } end && end < now) return Fail(); + + bool alreadyRedeemed = await _db.ViewerSerialCodeRedemptions + .AnyAsync(r => r.ViewerId == viewerId && r.SerialCodeId == code.Id, ct); + if (alreadyRedeemed) return Fail(); + + if (code.Rewards.Any(r => !IsSupportedGiftRewardType(r.RewardType))) return Fail(); + + try + { + _db.ViewerSerialCodeRedemptions.Add(new ViewerSerialCodeRedemption + { + ViewerId = viewerId, + SerialCodeId = code.Id, + RedeemedAt = now, + }); + + foreach (var reward in code.Rewards.OrderBy(r => r.Slot)) + { + _db.ViewerPresents.Add(new ViewerPresent + { + ViewerId = viewerId, + PresentId = Guid.NewGuid().ToString("N").Substring(0, 16), + Status = PresentStatus.Unclaimed, + RewardType = reward.RewardType, + RewardDetailId = reward.RewardDetailId, + RewardCount = reward.RewardCount, + Message = code.Message, + CreatedAt = now, + Source = $"serial_code:{code.Id}", + }); + } + + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException) + { + // Race: two concurrent redeems for the same (viewer, code). The composite PK + // on ViewerSerialCodeRedemption rejects the second one; treat as already-redeemed. + return Fail(); + } + + return Ok(new RegisterSerialCodeResponse { IsComplete = true }); + } + + private IActionResult Fail() => Ok(new { result_code = FailureResultCode }); + + /// + /// Gift wire types per GiftController.WireRewardTypeToUserGoodsType: + /// 1=Crystal, 4=Item, 9=Rupy. Codes with unsupported types fail-fast at redemption. + /// Note: wire "1" means Crystal (not RedEther), following the gift wire convention + /// rather than the enum order. + /// + private static bool IsSupportedGiftRewardType(int rewardType) => + rewardType is 1 or 4 or 9; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Campaign/RegisterSerialCodeRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Campaign/RegisterSerialCodeRequest.cs new file mode 100644 index 0000000..3ef5abb --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Campaign/RegisterSerialCodeRequest.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Campaign; + +/// +/// Body of POST /campaign/regist_serial_code. Client task: +/// MyPageCodeInputTask (Shadowverse_Code_2026-05-23/Wizard/MyPageCodeInputTask.cs). +/// +[MessagePackObject] +public sealed class RegisterSerialCodeRequest +{ + /// User-typed serial code. Case-sensitive on the server. + [JsonPropertyName("serial_code")] + [Key("serial_code")] + public string SerialCode { get; set; } = string.Empty; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Campaign/RegisterSerialCodeResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Campaign/RegisterSerialCodeResponse.cs new file mode 100644 index 0000000..3c57ba0 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Campaign/RegisterSerialCodeResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Campaign; + +/// +/// Success response shape. Failure path uses an anonymous { result_code = 4202 } +/// (mirroring AchievementController/MissionController) and bypasses this DTO. +/// +[MessagePackObject] +public sealed class RegisterSerialCodeResponse +{ + [JsonPropertyName("is_complete")] + [Key("is_complete")] + public bool IsComplete { get; set; } +} diff --git a/SVSim.UnitTests/Controllers/CampaignControllerTests.cs b/SVSim.UnitTests/Controllers/CampaignControllerTests.cs new file mode 100644 index 0000000..3d2cbf0 --- /dev/null +++ b/SVSim.UnitTests/Controllers/CampaignControllerTests.cs @@ -0,0 +1,230 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class CampaignControllerTests +{ + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + private static async Task SeedCodeAsync( + SVSimTestFactory factory, string code, string message = "test message", + bool enabled = true, DateTime? startAt = null, DateTime? endAt = null, + params (int Type, long DetailId, int Count)[] rewards) + { + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var entity = new SerialCodeEntry + { + Code = code, + Message = message, + IsEnabled = enabled, + StartAt = startAt, + EndAt = endAt, + }; + for (int i = 0; i < rewards.Length; i++) + { + entity.Rewards.Add(new SerialCodeRewardEntry + { + Slot = i, + RewardType = rewards[i].Type, + RewardDetailId = rewards[i].DetailId, + RewardCount = rewards[i].Count, + }); + } + ctx.SerialCodes.Add(entity); + await ctx.SaveChangesAsync(); + return entity; + } + + [Test] + public async Task Register_with_valid_unredeemed_code_returns_success_and_creates_presents() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + var code = await SeedCodeAsync(factory, "VALID1", "Welcome reward", + rewards: new[] { (1, 0L, 100), (9, 0L, 500) }); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/campaign/regist_serial_code", + JsonBody("""{"serial_code":"VALID1"}""")); + var raw = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw); + + using var doc = JsonDocument.Parse(raw); + Assert.That(doc.RootElement.GetProperty("is_complete").GetBoolean(), Is.True); + + using var verifyScope = factory.Services.CreateScope(); + var ctx = verifyScope.ServiceProvider.GetRequiredService(); + var presents = await ctx.ViewerPresents.AsNoTracking() + .Where(p => p.ViewerId == viewerId).OrderBy(p => p.RewardType).ToListAsync(); + Assert.That(presents, Has.Count.EqualTo(2)); + Assert.That(presents.All(p => p.Message == "Welcome reward"), Is.True); + Assert.That(presents.All(p => p.Source == $"serial_code:{code.Id}"), Is.True); + Assert.That(presents.All(p => p.Status == PresentStatus.Unclaimed), Is.True); + + var redemptions = await ctx.ViewerSerialCodeRedemptions.AsNoTracking() + .Where(r => r.ViewerId == viewerId && r.SerialCodeId == code.Id).ToListAsync(); + Assert.That(redemptions, Has.Count.EqualTo(1)); + } + + [Test] + public async Task Register_with_unknown_code_returns_4202() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/campaign/regist_serial_code", + JsonBody("""{"serial_code":"NOSUCHCODE"}""")); + var raw = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw); + + using var doc = JsonDocument.Parse(raw); + Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202)); + } + + [Test] + public async Task Register_with_disabled_code_returns_4202() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCodeAsync(factory, "DISABLED", enabled: false, + rewards: new[] { (1, 0L, 100) }); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/campaign/regist_serial_code", + JsonBody("""{"serial_code":"DISABLED"}""")); + var raw = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(raw); + Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202)); + } + + [Test] + public async Task Register_with_pre_start_code_returns_4202() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCodeAsync(factory, "FUTURE", + startAt: DateTime.UtcNow.AddDays(1), + rewards: new[] { (1, 0L, 100) }); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/campaign/regist_serial_code", + JsonBody("""{"serial_code":"FUTURE"}""")); + var raw = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(raw); + Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202)); + } + + [Test] + public async Task Register_with_expired_code_returns_4202() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCodeAsync(factory, "EXPIRED", + endAt: DateTime.UtcNow.AddDays(-1), + rewards: new[] { (1, 0L, 100) }); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/campaign/regist_serial_code", + JsonBody("""{"serial_code":"EXPIRED"}""")); + var raw = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(raw); + Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202)); + } + + [Test] + public async Task Register_with_already_redeemed_code_returns_4202() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + var code = await SeedCodeAsync(factory, "ONCE", + rewards: new[] { (1, 0L, 100) }); + + // Pre-seed a redemption record. + using (var seedScope = factory.Services.CreateScope()) + { + var ctx = seedScope.ServiceProvider.GetRequiredService(); + ctx.ViewerSerialCodeRedemptions.Add(new ViewerSerialCodeRedemption + { + ViewerId = viewerId, + SerialCodeId = code.Id, + RedeemedAt = DateTime.UtcNow.AddMinutes(-5), + }); + await ctx.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/campaign/regist_serial_code", + JsonBody("""{"serial_code":"ONCE"}""")); + var raw = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(raw); + Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202)); + + // Sanity: no new presents created. + using var verifyScope = factory.Services.CreateScope(); + var ctx2 = verifyScope.ServiceProvider.GetRequiredService(); + var presents = await ctx2.ViewerPresents.AsNoTracking() + .Where(p => p.ViewerId == viewerId).CountAsync(); + Assert.That(presents, Is.EqualTo(0)); + } + + [Test] + public async Task Register_with_unsupported_reward_type_returns_4202_and_no_redemption_row() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // RewardType 5 = Card; gift mapper supports only 1=Crystal, 4=Item, 9=Rupy. + var code = await SeedCodeAsync(factory, "BADTYPE", + rewards: new[] { (5, 100L, 1) }); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/campaign/regist_serial_code", + JsonBody("""{"serial_code":"BADTYPE"}""")); + var raw = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(raw); + Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202)); + + // Critical: no redemption row created → admin can fix and player can retry. + using var verifyScope = factory.Services.CreateScope(); + var ctx = verifyScope.ServiceProvider.GetRequiredService(); + var redemptions = await ctx.ViewerSerialCodeRedemptions.AsNoTracking() + .Where(r => r.ViewerId == viewerId && r.SerialCodeId == code.Id).CountAsync(); + Assert.That(redemptions, Is.EqualTo(0)); + } + + [Test] + public async Task Register_without_auth_returns_401() + { + using var factory = new SVSimTestFactory(); + var client = factory.CreateClient(); // no X-Test-Viewer-Id header + + var response = await client.PostAsync("/campaign/regist_serial_code", + JsonBody("""{"serial_code":"WHATEVER"}""")); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task Register_is_case_sensitive_for_code_match() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCodeAsync(factory, "MixedCase", + rewards: new[] { (1, 0L, 100) }); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/campaign/regist_serial_code", + JsonBody("""{"serial_code":"mixedcase"}""")); + var raw = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(raw); + Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202)); + } +}