322 lines
15 KiB
C#
322 lines
15 KiB
C#
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());
|
||
}
|
||
}
|