using System.Net.Http.Json; using System.Text; using System.Text.Json; using Kit.Helpers.SqlHelperAbstract; using Server.WatchBody.Core.Dal.Src.Domain; using Server.WatchBody.Core.Dal.Src.Repository; using Server.WatchBody.Core.Programs; using Xunit; namespace Server.WatchBody.Web.Tests; /// /// Интеграционные тесты admin API (specs 01–05 m08): Bearer-контур, /// атлеты (ключ один раз, блокировка → 401 sync), тренировки+CSV, /// программы (активация/применение rules), метрики, план/факт. /// [Collection("web")] public class AdminApiTests(TestApp app) { private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true }; private ConnectionString NewCs() => new() { Type = ConnectionStringType.Postgres, Value = app.Config.DbConnectionString, }; private async Task Client() { var http = new HttpClient { BaseAddress = app.Client.BaseAddress }; var login = await http.PostAsJsonAsync("/api/admin/login", new { login = "test-admin", password = "admin-test-pass" }); Assert.True(login.IsSuccessStatusCode, "login должен пройти"); var token = (await login.Content.ReadFromJsonAsync()) .GetProperty("token").GetString(); http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}"); return http; } private async Task LoginToken(string login, string password) { var http = app.Client; var response = await http.PostAsync("/api/admin/login", new StringContent($"{{\"login\":\"{login}\",\"password\":\"{password}\"}}", Encoding.UTF8, "application/json")); return response.IsSuccessStatusCode ? "ok" : $"{(int)response.StatusCode}"; } // --- spec 01: auth --- [Fact] public async Task AdminLogin_WrongPassword_401() { Assert.Equal("401", await LoginToken("test-admin", "неверно")); Assert.Equal("401", await LoginToken("чужой", "admin-test-pass")); } [Fact] public async Task AdminEndpoints_WithoutToken_401() { foreach (var path in new[] { "/api/admin/athletes", "/api/admin/workouts", "/api/admin/metrics?athleteId=1" }) { var response = await app.Client.GetAsync(path); Assert.Equal(401, (int)response.StatusCode); } } [Fact] public async Task AdminEndpoints_AthleteApiKeyRejected_401() { // 🛡1: атлетский ключ не действует в admin-контуре var request = new HttpRequestMessage(HttpMethod.Get, "/api/admin/athletes"); request.Headers.Add("X-Api-Key", TestApp.IvanKey); var response = await app.Client.SendAsync(request); Assert.Equal(401, (int)response.StatusCode); } // --- spec 02: атлеты --- [Fact] public async Task Athletes_CreateKeyOnce_BlockUnblock() { var http = await Client(); var name = "adm-" + Guid.NewGuid().ToString("N")[..8]; var create = await http.PostAsJsonAsync("/api/admin/athletes", new { name }); Assert.Equal(201, (int)create.StatusCode); var created = await create.Content.ReadFromJsonAsync(); var athleteId = created.GetProperty("id").GetInt32(); var apiKey = created.GetProperty("apiKey").GetString(); Assert.StartsWith("wb-", apiKey); // ключ работает на /api/v1 и не возвращается повторно var sync = await app.Client.PostAsync("/api/v1/sync", new StringContent("[]", Encoding.UTF8, "application/json")); var syncRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") { Content = new StringContent("[]", Encoding.UTF8, "application/json"), }; syncRequest.Headers.Add("X-Api-Key", apiKey); var withKey = await app.Client.SendAsync(syncRequest); Assert.True(withKey.IsSuccessStatusCode); // блокировка → 401 на sync var block = await http.PostAsJsonAsync($"/api/admin/athletes/{athleteId}/block", new { isBlocked = true }); Assert.True(block.IsSuccessStatusCode); var blockedRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") { Content = new StringContent("[]", Encoding.UTF8, "application/json"), }; blockedRequest.Headers.Add("X-Api-Key", apiKey); Assert.Equal(401, (int)(await app.Client.SendAsync(blockedRequest)).StatusCode); // смена ключа: старый не работает, новый работает var newKey = await http.PostAsync($"/api/admin/athletes/{athleteId}/key", content: null); var reissued = await newKey.Content.ReadFromJsonAsync(); Assert.NotEqual(apiKey, reissued.GetProperty("apiKey").GetString()); var oldKeyRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") { Content = new StringContent("[]", Encoding.UTF8, "application/json"), }; oldKeyRequest.Headers.Add("X-Api-Key", apiKey); Assert.Equal(401, (int)(await app.Client.SendAsync(oldKeyRequest)).StatusCode); // разблокировка await http.PostAsJsonAsync($"/api/admin/athletes/{athleteId}/block", new { isBlocked = false }); var unblockRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") { Content = new StringContent("[]", Encoding.UTF8, "application/json"), }; unblockRequest.Headers.Add("X-Api-Key", reissued.GetProperty("apiKey").GetString()); Assert.True((await app.Client.SendAsync(unblockRequest)).IsSuccessStatusCode); } // --- spec 03: тренировки + CSV --- [Fact] public async Task Workouts_ListCardCsv() { var cs = NewCs(); var athleteId = new AthleteRepository(cs).Insert(new Athlete { Name = "adm-w-" + Guid.NewGuid().ToString("N")[..6], ApiKeyHash = Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), }); var workoutId = Guid.NewGuid(); // workout_insert не пишет ended_at (errata m02) — дозакрытие Update var workoutsRepo = new WorkoutRepository(cs); workoutsRepo.Insert(athleteId, new Workout { Id = workoutId, AthleteId = athleteId, StartedAt = new DateTime(2026, 10, 16, 18, 0, 0, DateTimeKind.Utc), Status = "finished", SessionKind = "normal", FactsJson = null, Sets = [], Commands = [], }); new WorkoutRepository(cs).Update(athleteId, new Workout { Id = workoutId, AthleteId = athleteId, StartedAt = new DateTime(2026, 10, 16, 18, 0, 0, DateTimeKind.Utc), EndedAt = new DateTime(2026, 10, 16, 19, 0, 0, DateTimeKind.Utc), AvgHr = 110, Status = "finished", SessionKind = "normal", FactsJson = """{"schema":"workout-facts/1","laps":[{"index":0,"start":"2026-10-16T18:00:00Z","duration_s":300},{"index":1,"start":"2026-10-16T18:10:00Z","duration_s":300}],"records":[{"t_offset_s":10,"hr":100},{"t_offset_s":20,"hr":110},{"t_offset_s":610,"hr":120},{"t_offset_s":700,"hr":85}]}""", Sets = [], Commands = [], }); var setRepo = new WorkoutSetRepository(cs); var exerciseId = new ExerciseRepository(cs).Insert(new Exercise { Id = Guid.NewGuid(), AthleteId = athleteId, Name = "жим", GroupId = 1, IsBase = true, IsActive = true, ReplacementIds = [], }); setRepo.Insert(new WorkoutSet { Id = Guid.NewGuid(), WorkoutId = workoutId, AthleteId = athleteId, ExerciseId = exerciseId, Seq = 1, WeightKg = 80, Reps = 8, DoneAt = new DateTime(2026, 10, 16, 18, 5, 0, DateTimeKind.Utc), Source = "voice", InPlan = true, Rpe = 7, }); new WorkoutMetricsRepository(cs).Upsert(athleteId, new WorkoutMetrics { WorkoutId = workoutId, AthleteId = athleteId, Adherence = 100m, Tonnage = 640m, TonnageByGroup = "{}", E1pmByExercise = "{}", HrMetrics = "{}", RedFlags = "[]", }); var http = await Client(); var list = await http.GetFromJsonAsync( $"/api/admin/workouts?athleteId={athleteId}"); Assert.Equal(1, list.GetProperty("total").GetInt32()); var item = list.GetProperty("items")[0]; Assert.Equal(60, item.GetProperty("durationMin").GetInt32()); Assert.Equal(100m, item.GetProperty("adherence").GetDecimal()); var card = await http.GetFromJsonAsync($"/api/admin/workouts/{workoutId}"); Assert.Equal(1, card.GetProperty("sets").GetArrayLength()); Assert.Equal(2, card.GetProperty("timeline").GetArrayLength()); var timeline0 = card.GetProperty("timeline")[0]; Assert.Equal(105, timeline0.GetProperty("avgHr").GetInt32()); // (100+110)/2 Assert.Equal(110, timeline0.GetProperty("peakHr").GetInt32()); Assert.Equal(300, timeline0.GetProperty("restAfterS").GetInt32()); // 18:10 − 18:05 Assert.True(card.GetProperty("hrCurve").GetArrayLength() >= 4); var csv = await http.GetAsync($"/api/admin/workouts.csv?athleteId={athleteId}"); var text = await csv.Content.ReadAsStringAsync(); Assert.Equal("text/csv", csv.Content.Headers.ContentType!.MediaType); Assert.StartsWith("\ufeffathlete;date;duration_min;avg_hr;", text); Assert.Contains(";2026-10-16;60;", text, StringComparison.Ordinal); Assert.Contains(";60;110;1;1;100.0", text, StringComparison.Ordinal); } // --- spec 04: программы --- [Fact] public async Task Programs_ActivateAndApplyRules() { var cs = NewCs(); var athleteId = new AthleteRepository(cs).Insert(new Athlete { Name = "adm-p-" + Guid.NewGuid().ToString("N")[..6], ApiKeyHash = Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), }); var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs)); var user = store.CreateDraft(athleteId, """{"weeks":[{"week":1,"sessions":[]}]}""", "user", "база"); store.Activate(athleteId, user.Id); var rules = store.CreateDraft(athleteId, """{"weeks":[{"week":1,"sessions":[{"day":"monday","exercises":[]}]}]}""", "rules", "прогрессия"); var http = await Client(); var list = await http.GetFromJsonAsync($"/api/admin/programs?athleteId={athleteId}"); Assert.Equal(2, list.GetProperty("items").GetArrayLength()); // применение rules-предложения → author=admin, active; оригинал не тронут var apply = await http.PostAsync($"/api/admin/programs/{rules.Id}/apply?athleteId={athleteId}", content: null); Assert.True(apply.IsSuccessStatusCode); var applied = await apply.Content.ReadFromJsonAsync(); var appliedVersionId = Guid.Parse(applied.GetProperty("versionId").GetString()); Assert.Equal("active", applied.GetProperty("status").GetString()); var appliedRow = store.GetVersion(athleteId, appliedVersionId)!; Assert.Equal("admin", appliedRow.Author); Assert.Equal("draft", store.GetVersion(athleteId, rules.Id)!.Status); // 🛡4 Assert.Equal(user.Version + 2, appliedRow.Version); // применить не-rules — 400 var userDraft = store.CreateDraft(athleteId, """{"weeks":[]}""", "user", "v3"); var badApply = await http.PostAsync($"/api/admin/programs/{userDraft.Id}/apply?athleteId={athleteId}", content: null); Assert.Equal(400, (int)badApply.StatusCode); // активация активной — 400 var reActivate = await http.PostAsync( $"/api/admin/programs/{appliedVersionId}/activate?athleteId={athleteId}", content: null); Assert.Equal(400, (int)reActivate.StatusCode); } // --- spec 05: метрики + план/факт --- [Fact] public async Task Metrics_And_PlanSummary() { var cs = NewCs(); var athleteId = new AthleteRepository(cs).Insert(new Athlete { Name = "adm-m-" + Guid.NewGuid().ToString("N")[..6], ApiKeyHash = Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), }); var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs)); // якорь сводки — понедельник недели создания версии: даты от сегодня var today = DateOnly.FromDateTime(DateTime.UtcNow); var monday = today.AddDays(-(((int)today.DayOfWeek + 6) % 7)); var version = store.CreateDraft(athleteId, """{"weeks":[{"week":1,"sessions":[{"day":"monday","exercises":[{"exercise_id":"жим","sets":[{"weight_kg":80,"reps":8}]}]}]}]}""", "user", "тест"); store.Activate(athleteId, version.Id); var workoutsRepo = new WorkoutRepository(cs); var mondayWorkoutId = Guid.NewGuid(); workoutsRepo.Insert(athleteId, new Workout { Id = mondayWorkoutId, AthleteId = athleteId, StartedAt = monday.ToDateTimeAt(18), Status = "finished", SessionKind = "normal", Sets = [], Commands = [], }); workoutsRepo.Insert(athleteId, new Workout { Id = Guid.NewGuid(), AthleteId = athleteId, StartedAt = monday.AddDays(2).ToDateTimeAt(18), // ср — не плановый день Status = "finished", SessionKind = "normal", Sets = [], Commands = [], }); new WorkoutMetricsRepository(cs).Upsert(athleteId, new WorkoutMetrics { WorkoutId = mondayWorkoutId, AthleteId = athleteId, Adherence = 90m, Tonnage = 1000m, TonnageByGroup = "{}", E1pmByExercise = "{}", HrMetrics = "{}", RedFlags = "[]", }); var http = await Client(); var from = monday.AddDays(-3).ToString("yyyy-MM-dd"); var to = monday.AddDays(6).ToString("yyyy-MM-dd"); var metrics = await http.GetFromJsonAsync( $"/api/admin/metrics?athleteId={athleteId}&dateFrom={from}&dateTo={to}"); Assert.Equal(1, metrics.GetProperty("workouts").GetArrayLength()); Assert.Equal(1, metrics.GetProperty("weekly").GetArrayLength()); var weekly = metrics.GetProperty("weekly")[0]; Assert.Equal(1000m, weekly.GetProperty("tonnage").GetDecimal()); Assert.Equal(90m, weekly.GetProperty("avgAdherence").GetDecimal()); var plan = await http.GetFromJsonAsync($"/api/admin/plan?athleteId={athleteId}"); Assert.Equal(version.Version, plan.GetProperty("activeVersion").GetInt32()); var week1 = plan.GetProperty("weeks")[0]; Assert.Equal(1, week1.GetProperty("planned").GetInt32()); Assert.Equal(1, week1.GetProperty("performed").GetInt32()); // только пн; ср — не плановый день Assert.Equal(0, week1.GetProperty("missed").GetInt32()); Assert.Equal(90m, week1.GetProperty("avgAdherence").GetDecimal()); } }