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