306 lines
14 KiB
C#
306 lines
14 KiB
C#
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;
|
||
}
|
||
}
|