VEU.WathBody/3-app/2-cli/Server.WatchBody.Web.Tests/IntegrationTests.cs

306 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Xunit;
namespace Server.WatchBody.Web.Tests;
/// <summary>
/// Интеграционные тесты эндпоинтов (specs 02–05) против docker
/// watchbody-pg. Данные — фиксированные uuid (идемпотентность повторов).
/// </summary>
[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<HttpResponseMessage> 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<JsonElement>()).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<JsonElement>();
var secondAccepted = await second.Content.ReadFromJsonAsync<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<HttpResponseMessage> 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<JsonElement>();
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<JsonElement>();
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<JsonElement> ListWorkouts(string key, string query = "") =>
await (await GetWith(key, "/api/v1/workouts" + query)).Content.ReadFromJsonAsync<JsonElement>();
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;
}
}