using System.Net; using System.Net.Http.Json; using System.Text; using System.Text.Json; using Xunit; namespace Server.WatchBody.Web.Tests; /// /// Интеграционные тесты эндпоинтов (specs 02–05) против docker /// watchbody-pg. Данные — фиксированные uuid (идемпотентность повторов). /// [Collection("web")] public class IntegrationTests(TestApp app) { private static Guid U(int n) => Guid.Parse($"a0000000-0000-4000-8000-{n:D12}"); private const string Ivan = TestApp.IvanKey; private const string Petr = TestApp.PetrKey; private const string Anna = TestApp.AnnaKey; private static string Item(Guid guid, string entity, string data) => $$"""{"client_guid":"{{guid}}","entity":"{{entity}}","op":"upsert","data":{{data}}}"""; private async Task Sync(string key, params string[] items) { var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") { Content = new StringContent("[" + string.Join(",", items) + "]", Encoding.UTF8, "application/json"), }; request.Headers.Add("X-Api-Key", key); return await app.Client.SendAsync(request); } // --- middleware (spec 02) --- [Fact] public async Task Health_NoKey_Ok() { var response = await app.Client.GetAsync("/health"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] public async Task Workouts_WithoutKey_401() { var response = await app.Client.GetAsync("/api/v1/workouts"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Workouts_UnknownKey_401() { var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/workouts"); request.Headers.Add("X-Api-Key", "nobody-key"); Assert.Equal(HttpStatusCode.Unauthorized, (await app.Client.SendAsync(request)).StatusCode); } // --- sync (spec 03) --- [Fact] public async Task Sync_AllEntities_Accepted() { var workout = U(1); var response = await Sync(Ivan, Item(workout, "workout", $$"""{"workout_id":"{{workout}}","started_at":"2026-10-17T10:00:00Z","status":"finished","ended_at":"2026-10-17T11:00:00Z","session_kind":"normal","avg_hr":128}"""), Item(U(2), "workout_set", $$"""{"workout_id":"{{workout}}","exercise_id":null,"seq":1,"weight_kg":60,"reps":10,"done_at":"2026-10-17T10:05:00Z","source":"voice","in_plan":true,"rpe":7}"""), Item(U(3), "exercise", """{"exercise_id":"a0000000-0000-4000-8000-000000000003","name":"Test Жим лёжа","group":"push","is_base":true,"is_active":true}"""), Item(U(4), "program_version", """{"version":1,"based_on_version":null,"author":"user","rationale":"тест","weeks":[]}"""), Item(U(5), "profile", """{"sex":"m","birth_year":1980,"weight_kg":85.5,"hr_max":null}"""), Item(U(6), "daily_log", """{"date":"2026-10-17","bp_sys":128,"bp_dias":82,"weight_kg":85.5,"hr_resting":58,"wellbeing":4}"""), Item(U(7), "break", """{"kind":"illness","source":"manual","started_on":"2026-10-01","planned_return":"2026-10-10","note":"тест"}""")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var accepted = (await response.Content.ReadFromJsonAsync()).GetProperty("accepted"); Assert.Equal(7, accepted.GetArrayLength()); } [Fact] public async Task Sync_RepeatPackage_SameAccepted_NoDuplicates() { var workout = U(10); var package = new[] { Item(workout, "workout", $$"""{"workout_id":"{{workout}}","started_at":"2026-10-16T10:00:00Z"}"""), Item(U(11), "daily_log", """{"date":"2026-10-16","wellbeing":3}"""), }; var first = await Sync(Ivan, package); var second = await Sync(Ivan, package); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, second.StatusCode); var firstAccepted = await first.Content.ReadFromJsonAsync(); var secondAccepted = await second.Content.ReadFromJsonAsync(); Assert.Equal(firstAccepted.GetProperty("accepted").ToString(), secondAccepted.GetProperty("accepted").ToString()); // дублей нет: одна тренировка с этим started_at у Ивана (данные других тестов не в счёт) var list = await ListWorkouts(Ivan); var count = list.GetProperty("items").EnumerateArray() .Count(w => w.GetProperty("workoutId").GetString() == workout.ToString()); Assert.Equal(1, count); } [Fact] public async Task Sync_PackageOver50_400() { var items = Enumerable.Range(0, 51) .Select(i => Item(U(100 + i), "daily_log", """{"date":"2026-10-15"}""")) .ToArray(); Assert.Equal(HttpStatusCode.BadRequest, (await Sync(Ivan, items)).StatusCode); } [Fact] public async Task Sync_ProgramVersionStaleBasedOn_409() { // первая версия Анны (based_on_version = null → создаётся v1, latest = 1) await Sync(Anna, Item(U(120), "program_version", """{"version":1,"based_on_version":null,"author":"user","weeks":[]}""")); // телефон «отстал»: based_on_version = 5 при latest = 1 var response = await Sync(Anna, Item(U(121), "program_version", """{"version":6,"based_on_version":5,"author":"user","weeks":[]}""")); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); } [Fact] public async Task Sync_SetForForeignWorkout_400_AndRolledBack() { // своя тренировка Ивана; пакет Петра ссылается на неё var workout = U(140); await Sync(Ivan, Item(workout, "workout", $$"""{"workout_id":"{{workout}}","started_at":"2026-10-13T10:00:00Z"}""")); var response = await Sync(Petr, Item(U(130), "workout_set", $$"""{"workout_id":"{{workout}}","exercise_id":null,"seq":1,"weight_kg":50,"reps":8,"done_at":"2026-10-13T10:05:00Z","source":"watch","in_plan":true}"""), Item(U(131), "daily_log", """{"date":"2026-10-14","wellbeing":2}""")); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); // откат пакета: 401-канал не нужен — просто проверяем, что Петру // доступ по-прежнему работает Assert.Equal(HttpStatusCode.OK, (await GetWith(Petr, "/api/v1/workouts")).StatusCode); } [Fact] public async Task Sync_BadJson_400() { var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") { Content = new StringContent("{not json", Encoding.UTF8, "application/json"), }; request.Headers.Add("X-Api-Key", Ivan); var response = await app.Client.SendAsync(request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } // --- чтение (spec 04) --- [Fact] public async Task Workouts_Isolation_Pagination() { // 3 тренировки Анны (разные started_at — порядок предсказуем) for (var i = 0; i < 3; i++) { var w = U(200 + i); await Sync(Anna, Item(w, "workout", $$"""{"workout_id":"{{w}}","started_at":"2026-10-0{{i + 1}}T09:00:00Z"}""")); } var all = await ListWorkouts(Anna); var annaItems = all.GetProperty("items").EnumerateArray() .Where(w => w.GetProperty("workoutId").GetString()!.StartsWith("a0000000-0000-4000-8000-0000000002")).ToList(); Assert.Equal(3, annaItems.Count); var limit = await ListWorkouts(Anna, "?limit=2&offset=1"); Assert.Equal(2, limit.GetProperty("items").EnumerateArray() .Count(w => w.GetProperty("workoutId").GetString()!.StartsWith("a0000000-0000-4000-8000-0000000002"))); // изоляция: Петр не видит тренировки Анны var petr = await ListWorkouts(Petr); Assert.DoesNotContain(petr.GetProperty("items").EnumerateArray(), w => w.GetProperty("workoutId").GetString()!.StartsWith("a0000000-0000-4000-8000-0000000002")); } [Fact] public async Task Workouts_LimitOutOfRange_400() { Assert.Equal(HttpStatusCode.BadRequest, (await GetWith(Ivan, "/api/v1/workouts?limit=0")).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await GetWith(Ivan, "/api/v1/workouts?limit=101")).StatusCode); } [Fact] public async Task WorkoutCard_Own_WithSets_Foreign_404() { var own = await GetWith(Ivan, $"/api/v1/workouts/{U(1)}"); Assert.Equal(HttpStatusCode.OK, own.StatusCode); var card = await own.Content.ReadFromJsonAsync(); Assert.Equal(1, card.GetProperty("sets").GetArrayLength()); Assert.Equal(HttpStatusCode.NotFound, (await GetWith(Petr, $"/api/v1/workouts/{U(1)}")).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await GetWith(Ivan, "/api/v1/workouts/not-a-uuid")).StatusCode); } // --- программы (spec 05) --- [Fact] public async Task Programs_Active_Or404() { // Анна: новая версия через sync (создаётся draft), затем active — // через репозиторий БД (в v1 active ставит сервер при переключении, m03). // uuid случайный: program_version_insert не идемпотентен (version=max+1) var versionId = Guid.NewGuid(); await Sync(Anna, Item(versionId, "program_version", """{"version":9,"based_on_version":null,"author":"user","weeks":[]}""")); var cs = new Kit.Helpers.SqlHelperAbstract.ConnectionString { Type = Kit.Helpers.SqlHelperAbstract.ConnectionStringType.Postgres, Value = app.Config.DbConnectionString, }; new Server.WatchBody.Core.Dal.Src.Repository.ProgramVersionRepository(cs) .UpdateStatus(FindAthleteId(Anna), versionId, "active"); var response = await GetWith(Anna, "/api/v1/programs/active"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var program = await response.Content.ReadFromJsonAsync(); Assert.Equal("active", program.GetProperty("status").GetString()); Assert.True(program.TryGetProperty("body", out _)); // у Петра программы нет → 404 Assert.Equal(HttpStatusCode.NotFound, (await GetWith(Petr, "/api/v1/programs/active")).StatusCode); } private async Task GetWith(string key, string path) { var request = new HttpRequestMessage(HttpMethod.Get, path); request.Headers.Add("X-Api-Key", key); return await app.Client.SendAsync(request); } // --- З-41(а): is_active=false в sync — логическое удаление (Ф-4.2) --- [Fact] public async Task Sync_ExerciseInactive_LogicalDelete() { var key = "del-ex-" + Guid.NewGuid().ToString("N")[..12]; var cs = new Kit.Helpers.SqlHelperAbstract.ConnectionString { Type = Kit.Helpers.SqlHelperAbstract.ConnectionStringType.Postgres, Value = app.Config.DbConnectionString, }; new Server.WatchBody.Core.Dal.Src.Repository.AthleteRepository(cs).Insert( new Server.WatchBody.Core.Dal.Src.Domain.Athlete { Name = "Del exercise", ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(key), }); var exGuid = Guid.NewGuid(); var first = await Sync(key, Item(exGuid, "exercise", """{"name":"Test Присед","group":"legs","is_base":true,"is_active":true}""")); Assert.True(first.IsSuccessStatusCode, $"sync 1: {(int)first.StatusCode} {await first.Content.ReadAsStringAsync()}"); var accepted1 = await first.Content.ReadFromJsonAsync(); Assert.Equal(exGuid, accepted1.GetProperty("accepted")[0].GetGuid()); // повтор того же упражнения с is_active=false → удаление, GUID в accepted var second = await Sync(key, Item(exGuid, "exercise", """{"name":"Test Присед","group":"legs","is_base":true,"is_active":false}""")); Assert.True(second.IsSuccessStatusCode, $"sync 2: {(int)second.StatusCode} {await second.Content.ReadAsStringAsync()}"); var accepted2 = await second.Content.ReadFromJsonAsync(); Assert.Equal(exGuid, accepted2.GetProperty("accepted")[0].GetGuid()); var row = new Server.WatchBody.Core.Dal.Src.Repository.ExerciseRepository(cs) .Select(new Server.WatchBody.Core.Dal.Src.Repository.ExerciseFilter { AthleteId = FindAthleteId(key), Name = "Test Присед", }).Single(); Assert.False(row.IsActive); // идемпотентность: повтор false — 200, состояние не меняется var third = await Sync(key, Item(exGuid, "exercise", """{"name":"Test Присед","group":"legs","is_base":true,"is_active":false}""")); Assert.True(third.IsSuccessStatusCode); } private async Task ListWorkouts(string key, string query = "") => await (await GetWith(key, "/api/v1/workouts" + query)).Content.ReadFromJsonAsync(); private int FindAthleteId(string apiKey) { var cs = new Kit.Helpers.SqlHelperAbstract.ConnectionString { Type = Kit.Helpers.SqlHelperAbstract.ConnectionStringType.Postgres, Value = app.Config.DbConnectionString, }; var athlete = new Server.WatchBody.Core.Dal.Src.Repository.AthleteRepository(cs) .Select(new Server.WatchBody.Core.Dal.Src.Repository.AthleteFilter { ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(apiKey), }).First(); return athlete.Id; } }