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

322 lines
15 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.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;
/// <summary>
/// Интеграционные тесты admin API (specs 01–05 m08): Bearer-контур,
/// атлеты (ключ один раз, блокировка → 401 sync), тренировки+CSV,
/// программы (активация/применение rules), метрики, план/факт.
/// </summary>
[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<HttpClient> 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<JsonElement>())
.GetProperty("token").GetString();
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
return http;
}
private async Task<string> 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<JsonElement>();
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<JsonElement>();
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<JsonElement>(
$"/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<JsonElement>($"/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<JsonElement>($"/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<JsonElement>();
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<JsonElement>(
$"/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<JsonElement>($"/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());
}
}