feat(campaign): CampaignController.RegisterSerialCode + DTOs + tests
Adds POST /campaign/regist_serial_code with 9 integration tests covering success path, all disqualifier conditions (unknown/disabled/expired/future/ already-redeemed/unsupported-reward-type), 401 on missing auth, and case- sensitivity. IsSupportedGiftRewardType uses gift-wire literals (1/4/9) not UserGoodsType enum values, matching GiftController.WireRewardTypeToUserGoodsType. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
93
SVSim.EmulatedEntrypoint/Controllers/CampaignController.cs
Normal file
93
SVSim.EmulatedEntrypoint/Controllers/CampaignController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// /campaign/* — promotional surfaces. Currently just <c>regist_serial_code</c>.
|
||||
/// </summary>
|
||||
[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<IActionResult> 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 });
|
||||
|
||||
/// <summary>
|
||||
/// Gift wire types per <c>GiftController.WireRewardTypeToUserGoodsType</c>:
|
||||
/// 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 <see cref="UserGoodsType"/> enum order.
|
||||
/// </summary>
|
||||
private static bool IsSupportedGiftRewardType(int rewardType) =>
|
||||
rewardType is 1 or 4 or 9;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Campaign;
|
||||
|
||||
/// <summary>
|
||||
/// Body of <c>POST /campaign/regist_serial_code</c>. Client task:
|
||||
/// <c>MyPageCodeInputTask</c> (Shadowverse_Code_2026-05-23/Wizard/MyPageCodeInputTask.cs).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class RegisterSerialCodeRequest
|
||||
{
|
||||
/// <summary>User-typed serial code. Case-sensitive on the server.</summary>
|
||||
[JsonPropertyName("serial_code")]
|
||||
[Key("serial_code")]
|
||||
public string SerialCode { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Campaign;
|
||||
|
||||
/// <summary>
|
||||
/// Success response shape. Failure path uses an anonymous <c>{ result_code = 4202 }</c>
|
||||
/// (mirroring AchievementController/MissionController) and bypasses this DTO.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class RegisterSerialCodeResponse
|
||||
{
|
||||
[JsonPropertyName("is_complete")]
|
||||
[Key("is_complete")]
|
||||
public bool IsComplete { get; set; }
|
||||
}
|
||||
230
SVSim.UnitTests/Controllers/CampaignControllerTests.cs
Normal file
230
SVSim.UnitTests/Controllers/CampaignControllerTests.cs
Normal file
@@ -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<SerialCodeEntry> 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<SVSimDbContext>();
|
||||
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<SVSimDbContext>();
|
||||
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<SVSimDbContext>();
|
||||
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<SVSimDbContext>();
|
||||
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<SVSimDbContext>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user