using System.Security.Claims; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Progression.Challenges.V2; using RadicadeServer.API.Storefront; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Avatar; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; public class WeeklyChallengeTests { [Fact] public async Task AdminLibrarySchedulesNextWeekAndPlayerEndpointUsesSavedLineup() { await using var db = Db(); var oldKey = DBConfig.AdminApiKey; var oldLogs = DBConfig.PersistentLogs; var oldEquipment = DBConfig.DefaultEquipmentItems; var oldAvatars = DBConfig.DefaultAvatarCatalog; DBConfig.DefaultEquipmentItems = JsonSerializer.Serialize(new[] { new Equipment { PrefabName = "Disc", ModificationGuid = "test-skin", FriendlyName = "Test skin" } }); DBConfig.DefaultAvatarCatalog = "[]"; var folder = Path.Combine(Path.GetTempPath(), "radicade-weekly-tests-" + Guid.NewGuid().ToString("N")); DBConfig.AdminApiKey = "weekly-test-key"; DBConfig.PersistentLogs = folder; try { var api = new RadicadeServer.Admin.AdminPanelAPI(db); var now = DateTime.UtcNow; var current = FixtureMap(now); var next = FixtureMap(current.EndAt); var ids = new List(); foreach (var definition in current.Challenges.Take(3)) { var payload = JsonSerializer.SerializeToElement(new { name = "Admin " + definition.Name, description = definition.Description, tooltip = definition.Tooltip, config = definition.Config }); var result = Assert.IsType(await api.Insert("challenges", "weekly-test-key", payload)); ids.Add(Assert.IsType(result.Value)["id"]!.GetValue()); } var request = new RadicadeServer.Admin.WeeklyScheduleRequest { ChallengeMapId = next.ChallengeMapId, ChallengeIds = ids, RewardKey = "equipment:Disc:test-skin" }; Assert.IsType(await api.SaveWeeklySchedule("wrong", request)); Assert.IsType(await api.SaveWeeklySchedule("weekly-test-key", request)); Assert.Equal(5, (await WeeklyChallenges.GetAsync(db, 42, now)).Challenges.Count); var scheduled = await WeeklyChallenges.GetAsync(db, 42, current.EndAt); Assert.Equal(3, scheduled.Challenges.Count); Assert.All(scheduled.Challenges, c => Assert.StartsWith("Admin ", c.Name)); // A library edit doesn't change the already-published snapshot. await api.Update("challenges", ids[0], "weekly-test-key", JsonSerializer.SerializeToElement(new { name = "Draft change" })); Assert.Equal(scheduled.Challenges[0].Name, (await WeeklyChallenges.GetAsync(db, 42, current.EndAt)).Challenges[0].Name); foreach (var id in ids) await Complete(db, (int)id, now: current.EndAt); Assert.Single(StorefrontShared.ParseGiftList((await db.Gifts.SingleAsync()).Data)); var published = await WeeklyChallenges.GetDefinitionAsync(db, current.EndAt); Assert.IsType(await api.SaveWeeklySchedule("weekly-test-key", request)); request.ChallengeMapId = current.ChallengeMapId; DBConfig.DefaultAvatarCatalog = JsonSerializer.Serialize(new[] { new AvatarItem { AvatarItemDesc = "admin-avatar", FriendlyName = "Admin avatar", AvatarItemType = 0 } }); request.RewardKey = "avatar:admin-avatar"; Assert.IsType(await api.SaveWeeklySchedule("weekly-test-key", request)); Assert.Equal("admin-avatar", (await WeeklyChallenges.GetAsync(db, 42, now)).Gift.AvatarItemDesc); foreach (var id in ids) await Complete(db, (int)id, now: now); Assert.Contains(StorefrontShared.ParseGiftList((await db.Gifts.SingleAsync()).Data), g => g.AvatarItemDesc == "admin-avatar"); request.Revision = (await WeeklyChallenges.GetDefinitionAsync(db, now)).Revision; request.RewardKey = "avatar:nonexistent"; Assert.IsType(await api.SaveWeeklySchedule("weekly-test-key", request)); request.RewardKey = "equipment:Disc:test-skin"; request.ChallengeMapId = current.ChallengeMapId - 1; Assert.IsType(await api.SaveWeeklySchedule("weekly-test-key", request)); request.ChallengeMapId = next.ChallengeMapId; request.ChallengeIds = [ids[0], ids[0], ids[1]]; Assert.IsType(await api.SaveWeeklySchedule("weekly-test-key", request)); request.ChallengeIds = [ids[0], ids[1], 99999]; Assert.IsType(await api.SaveWeeklySchedule("weekly-test-key", request)); } finally { DBConfig.DefaultEquipmentItems = oldEquipment; DBConfig.DefaultAvatarCatalog = oldAvatars; DBConfig.AdminApiKey = oldKey; DBConfig.PersistentLogs = oldLogs; if (Directory.Exists(folder)) Directory.Delete(folder, true); } } [Fact] public async Task UnpublishedWeekHasNoContentAndRejectsProgress() { await using var db = Db(); db.ServerConfig.RemoveRange(db.ServerConfig); await db.SaveChangesAsync(); var map = await WeeklyChallenges.GetAsync(db, 42, Now); Assert.False(map.Enabled); Assert.Empty(map.Challenges); Assert.Empty(map.Gift.EquipmentModificationGuid); Assert.Equal("weekly_not_published", await WeeklyChallenges.UpdateAsync(db, 42, map.ChallengeMapId, 1, "{}", true, Now)); } [Fact] public async Task CurrentEditsPreserveMatchingProgressAndNeverReaward() { await using var db = Db(); await Complete(db, 1); var definition = FixtureMap(Now); definition.Gift = new Gift { AvatarItemDesc = "test-avatar", FriendlyName = "Chosen avatar", Context = RadicadeServer.RecNet.Enums.Store.GiftContext.All_Weekly_Challenge_Complete }; var record = await db.ServerConfig.SingleAsync(x => x.Key == WeeklyChallenges.ScheduleKey(definition.ChallengeMapId)); record.Value = JsonSerializer.Serialize(definition); await db.SaveChangesAsync(); Assert.True((await WeeklyChallenges.GetAsync(db, 42, Now)).Challenges[0].Complete); await Complete(db, 2); await Complete(db, 3); Assert.Equal("test-avatar", Assert.Single(StorefrontShared.ParseGiftList((await db.Gifts.SingleAsync()).Data)).AvatarItemDesc); definition.Challenges[0].Config = definition.Challenges[3].Config; record = await db.ServerConfig.SingleAsync(x => x.Key == WeeklyChallenges.ScheduleKey(definition.ChallengeMapId)); record.Value = JsonSerializer.Serialize(definition); await db.SaveChangesAsync(); var updated = await WeeklyChallenges.GetAsync(db, 42, Now); Assert.False(updated.Challenges[0].Complete); Assert.True(updated.CompletedRequired); await Complete(db, 1); Assert.Single(StorefrontShared.ParseGiftList((await db.Gifts.SingleAsync()).Data)); definition.Enabled = false; record = await db.ServerConfig.SingleAsync(x => x.Key == WeeklyChallenges.ScheduleKey(definition.ChallengeMapId)); record.Value = JsonSerializer.Serialize(definition); await db.SaveChangesAsync(); Assert.Empty((await WeeklyChallenges.GetAsync(db, 42, Now)).Challenges); Assert.Equal("weekly_not_published", await WeeklyChallenges.UpdateAsync(db, 42, definition.ChallengeMapId, 1, definition.Challenges[0].Config, false, Now)); } [Fact] public async Task PendingAvatarGetsConfiguredFallback() { await using var db = Db(); var map = FixtureMap(Now); map.Gift = new Gift { AvatarItemDesc = "test-avatar", Context = RadicadeServer.RecNet.Enums.Store.GiftContext.All_Weekly_Challenge_Complete }; map.FallbackTokens = 725; (await db.ServerConfig.SingleAsync(x => x.Key == WeeklyChallenges.ScheduleKey(map.ChallengeMapId))).Value = JsonSerializer.Serialize(map); await db.SaveChangesAsync(); await StorefrontShared.PersistGiftBoxAsync(db, 42, new Gift { GiftDropId = 99, AvatarItemDesc = "test-avatar" }, notify: false); await Complete(db, 1); await Complete(db, 2); await Complete(db, 3); var gifts = StorefrontShared.ParseGiftList((await db.Gifts.SingleAsync()).Data); Assert.Equal(2, gifts.Count); Assert.Equal(725, gifts.Single(x => x.GiftDropId != 99).Currency); } [Fact] public void WeeklyDefinitionValidationRejectsCompletedAndUnrecognizedPredicates() { foreach (var goal in FixtureMap(Now).Challenges) Assert.True(WeeklyChallenges.IsSupportedDefinition(goal.Config)); Assert.False(WeeklyChallenges.IsSupportedDefinition("{}")); Assert.False(WeeklyChallenges.IsSupportedDefinition("{\"ct\":0,\"rc\":[{\"ct\":6,\"vs\":[999]}]}")); Assert.False(WeeklyChallenges.IsSupportedDefinition(Completed(FixtureMap(Now).Challenges[0].Config))); } private static readonly DateTime Now = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc); private static RecDBContext Db(string? name = null) { var db = new RecDBContext(TestDbOptions.Create().UseInMemoryDatabase(name ?? Guid.NewGuid().ToString()).Options); foreach (var time in new[] { Now, DateTime.UtcNow }) { var map = FixtureMap(time); var key = WeeklyChallenges.ScheduleKey(map.ChallengeMapId); if (!db.ServerConfig.Any(x => x.Key == key)) db.ServerConfig.Add(new RadicadeServer.RecNet.Config.ServerConfigVal(key, JsonSerializer.Serialize(map))); db.SaveChanges(); } return db; } private static RadicadeServer.RecNet.Progression.Challenges.WeeklyChallengeMap FixtureMap(DateTime now) { var map = WeeklyChallenges.CreateMap(now); map.Enabled = true; map.Challenges = new[] { 2, 4, 5, 10, 8 }.Select((e, i) => new RadicadeServer.RecNet.Progression.Challenges.WeeklyChallenge { ChallengeId = i + 1, Name = "Goal " + (i + 1), Description = "Test goal", Config = JsonSerializer.Serialize(new { ct = 0, rc = new[] { new { ct = 6, vs = new[] { e } } } }) }).ToList(); map.Gift = new Gift { EquipmentPrefabName = "Disc", EquipmentModificationGuid = "9d2a3618-12b3-4ca0-be59-ed15d7926d77", Context = RadicadeServer.RecNet.Enums.Store.GiftContext.All_Weekly_Challenge_Complete }; return map; } private static string Completed(string config) { var node = JsonNode.Parse(config)!; node["c"] = true; node["ipc"] = false; node["wc"] = new JsonArray(); return node.ToJsonString(); } private static async Task Complete(RecDBContext db, int id, long player = 42, DateTime? now = null) { var time = now ?? Now; var map = await WeeklyChallenges.GetAsync(db, player, time); Assert.Null(await WeeklyChallenges.UpdateAsync(db, player, map.ChallengeMapId, id, Completed(map.Challenges.Single(x => x.ChallengeId == id).Config), true, time)); } [Fact] public async Task ThreeOfFivePaysOnceIncludingRetriesAndAllFive() { await using var db = Db(); await Complete(db, 1); await Complete(db, 2); Assert.Empty(await db.Gifts.ToListAsync()); await Complete(db, 3); await Complete(db, 3); await Complete(db, 4); await Complete(db, 5); var gifts = StorefrontShared.ParseGiftList((await db.Gifts.SingleAsync()).Data); Assert.Single(gifts); Assert.Equal("9d2a3618-12b3-4ca0-be59-ed15d7926d77", gifts[0].EquipmentModificationGuid); Assert.True((await WeeklyChallenges.GetAsync(db, 42, Now)).CompletedRequired); Assert.Equal(gifts[0].GiftDropId, (await db.WeeklyChallenges.SingleAsync()).RewardGiftDropId); } [Fact] public async Task ReconnectPersistsAndPlayersAreIsolated() { var name = Guid.NewGuid().ToString(); await using (var db = Db(name)) await Complete(db, 1); await using var reopened = Db(name); Assert.True((await WeeklyChallenges.GetAsync(reopened, 42, Now)).Challenges[0].Complete); Assert.False((await WeeklyChallenges.GetAsync(reopened, 43, Now)).Challenges[0].Complete); } [Fact] public async Task RolloverResetsAndRejectsOldWeek() { await using var db = Db(); var before = FixtureMap(Now); await Complete(db, 1); var next = await WeeklyChallenges.GetAsync(db, 42, before.EndAt); Assert.Equal(before.ChallengeMapId + 1, next.ChallengeMapId); Assert.All(next.Challenges, x => Assert.False(x.Complete)); Assert.False(next.Enabled); Assert.Empty(next.Challenges); Assert.Equal("stale_challenge_map", await WeeklyChallenges.UpdateAsync(db, 42, before.ChallengeMapId, 1, Completed(before.Challenges[0].Config), true, before.EndAt)); } [Fact] public async Task RejectsAlteredDefinitionsAndInconsistentCompletion() { await using var db = Db(); var map = FixtureMap(Now); var changed = JsonNode.Parse(Completed(map.Challenges[0].Config))!; changed["rc"]![0]!["vs"]![0] = 0; Assert.Equal("invalid_challenge_config", await WeeklyChallenges.UpdateAsync(db, 42, map.ChallengeMapId, 1, changed.ToJsonString(), true, Now)); Assert.Equal("invalid_challenge_config", await WeeklyChallenges.UpdateAsync(db, 42, map.ChallengeMapId, 1, map.Challenges[0].Config, true, Now)); Assert.Equal("unknown_challenge", await WeeklyChallenges.UpdateAsync(db, 42, map.ChallengeMapId, 100, "{}", true, Now)); Assert.Empty(await db.WeeklyChallenges.ToListAsync()); } [Fact] public async Task OwnedSkinGetsOneTokenBox() { await using var db = Db(); var gift = FixtureMap(Now).Gift; db.Unlocks.Add(new PlayerUnlocks { AccountId = 42, Equipment = JsonSerializer.Serialize(new[] { new Equipment { PrefabName = gift.EquipmentPrefabName, ModificationGuid = gift.EquipmentModificationGuid } }) }); await db.SaveChangesAsync(); await Complete(db, 1); await Complete(db, 2); await Complete(db, 3); await Complete(db, 3); var box = Assert.Single(StorefrontShared.ParseGiftList((await db.Gifts.SingleAsync()).Data)); Assert.Equal(300, box.Currency); Assert.True(string.IsNullOrEmpty(box.EquipmentModificationGuid)); } [Fact] public async Task ConsumedBoxIsNeverRecreatedAndCompletionCannotRegress() { await using var db = Db(); await Complete(db, 1); await Complete(db, 2); await Complete(db, 3); var row = await db.Gifts.SingleAsync(); row.Data = "[]"; await db.SaveChangesAsync(); var fresh = FixtureMap(Now); Assert.Null(await WeeklyChallenges.UpdateAsync(db, 42, fresh.ChallengeMapId, 1, fresh.Challenges[0].Config, false, Now)); await Complete(db, 3); Assert.Empty(StorefrontShared.ParseGiftList((await db.Gifts.SingleAsync()).Data)); Assert.True((await WeeklyChallenges.GetAsync(db, 42, Now)).Challenges[0].Complete); } [Fact] public async Task ConcurrentCompletionsDoNotLoseProgressOrDuplicateReward() { var name = Guid.NewGuid().ToString(); await using (var seed = Db(name)) { } await Task.WhenAll(Enumerable.Range(1, 5).Select(async id => { await using var db = Db(name); await Complete(db, id); })); await using var check = Db(name); Assert.All((await WeeklyChallenges.GetAsync(check, 42, Now)).Challenges, x => Assert.True(x.Complete)); Assert.Single(StorefrontShared.ParseGiftList((await check.Gifts.SingleAsync()).Data)); } [Theory] [InlineData(true)] [InlineData(false)] public async Task EndpointAcceptsClientFormAndJson(bool form) { await using var db = Db(); var now = DateTime.UtcNow; var map = FixtureMap(now); var fields = new Dictionary { ["ChallengeMapId"] = map.ChallengeMapId.ToString(), ["ChallengeId"] = "1", ["Complete"] = "True", ["Config"] = Completed(map.Challenges[0].Config) }; var body = form ? await new FormUrlEncodedContent(fields).ReadAsStringAsync() : JsonSerializer.Serialize(fields); var context = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "42") }, "test")) }; context.Request.ContentType = form ? "application/x-www-form-urlencoded" : "application/json"; context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); var api = new ChallengeV2(db) { ControllerContext = new ControllerContext { HttpContext = context } }; Assert.IsType(await api.UpdateProgress()); var response = Assert.IsType(await api.GetCurrent()); var wire = JsonSerializer.SerializeToElement(response.Value); Assert.Equal(JsonValueKind.String, wire.GetProperty("Challenges")[0].GetProperty("Config").ValueKind); Assert.True(wire.GetProperty("Challenges")[0].GetProperty("Complete").GetBoolean()); Assert.Equal(5, wire.GetProperty("Challenges").GetArrayLength()); } }