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;
}
}