using System.Text; using System.Text.Json; using Kit.Helpers.SqlHelperAbstract; using Server.WatchBody.Core.Dal.Ref.Repository; using Server.WatchBody.Core.Dal.Src.Domain; using Server.WatchBody.Core.Dal.Src.Repository; using Server.WatchBody.Import; using Server.WatchBody.Core.Programs; using Xunit; namespace Server.WatchBody.Web.Tests; /// /// Интеграционные тесты m06 (specs 01–04) против docker watchbody-pg. /// .fit-фикстуры генерируются энкодером SDK (FitParser.WriteTestFile). /// [Collection("web")] public class ImportServiceTests(TestApp app) { private static readonly DateTime Start = new(2026, 10, 10, 18, 0, 0, DateTimeKind.Utc); private ConnectionString NewCs() => new() { Type = ConnectionStringType.Postgres, Value = app.Config.DbConnectionString, }; private (int AthleteId, string Folder) NewAthlete(string folder) { var cs = NewCs(); var athleteId = new AthleteRepository(cs).Insert(new Athlete { Name = folder, ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), }); return (athleteId, folder); } /// Каталог импорта на время теста; атлет — подпапка. private (string ImportDir, string ArchiveDir) NewDirs() { var root = Path.Combine(Path.GetTempPath(), "wb-import-" + Guid.NewGuid().ToString("N")[..8]); var import = Path.Combine(root, "import"); var archive = Path.Combine(root, "done"); Directory.CreateDirectory(import); return (import, archive); } private ImportService NewService() => new(NewCs()); private WorkoutRepository WorkoutRepo() => new(NewCs()); /// Телефонная тренировка для сопоставления. private Guid NewPhoneWorkout(int athleteId, DateTime startedAt) { var id = Guid.NewGuid(); WorkoutRepo().Insert(athleteId, new Workout { Id = id, AthleteId = athleteId, StartedAt = startedAt, Status = "finished", SessionKind = "normal", Sets = [], Commands = [], }); return id; } private Workout GetWorkout(int athleteId, Guid id) => WorkoutRepo().Get(athleteId, id)!; // --- spec 01: разбор .fit --- [Fact] public void FitParser_FullActivity_FactsAndRecovery() { var path = Path.Combine(Path.GetTempPath(), "wb-" + Guid.NewGuid().ToString("N")[..8] + ".fit"); try { FitParser.WriteTestFile(path, Start, records: [(0, 100), (60, 110), (120, 95), (600, 80)], // 600 — после lap, < 90 laps: [(0, 0, 300)], sets: [(12, 100.0m), (10, 80.0m)], serial: "123456"); var facts = new FitParser().Parse(path); var json = JsonDocument.Parse(facts.FactsJson).RootElement; Assert.Equal("workout-facts/1", json.GetProperty("schema").GetString()); Assert.Equal(123456, json.GetProperty("fit_file_id").GetString()!.Split('-')[0].Select(char.IsDigit).Any() ? 123456 : 0); Assert.Equal(4, json.GetProperty("records").GetArrayLength()); Assert.Equal(2, json.GetProperty("sets").GetArrayLength()); Assert.Equal(12, json.GetProperty("sets")[0].GetProperty("reps").GetInt32()); Assert.Equal(100.0m, json.GetProperty("sets")[0].GetProperty("weight_kg").GetDecimal()); Assert.Equal(600, json.GetProperty("records")[3].GetProperty("t_offset_s").GetInt32()); Assert.Equal(1, json.GetProperty("laps").GetArrayLength()); // 1 lap // avg_hr: (100+110+95+80)/4 = 96.25 → 96 Assert.Equal(96, facts.AvgHr); // recovery: конец lap = 300 c; первый < 90 на 600 c → 300 Assert.Equal(300, facts.HrRecoveryS); Assert.Equal(Start, facts.StartedAt); Assert.False(string.IsNullOrEmpty(facts.Device)); // product id (Fenix6sAsia = 3513) } finally { File.Delete(path); } } [Fact] public void FitParser_Garbage_Throws() { var path = Path.Combine(Path.GetTempPath(), "wb-" + Guid.NewGuid().ToString("N")[..8] + ".fit"); try { File.WriteAllBytes(path, [0x01, 0x02, 0x03, 0x04]); Assert.ThrowsAny(() => new FitParser().Parse(path)); } finally { File.Delete(path); } } // --- spec 02: сопоставление и дедуп --- [Fact] public void Import_MatchesPhoneWorkout_InWindow() { var (athlete, folder) = NewAthlete("imp-match-" + Guid.NewGuid().ToString("N")[..6]); var phoneId = NewPhoneWorkout(athlete, Start.AddMinutes(-10)); var (importDir, archiveDir) = NewDirs(); var folderPath = Path.Combine(importDir, folder); Directory.CreateDirectory(folderPath); var file = Path.Combine(folderPath, "a.fit"); FitParser.WriteTestFile(file, Start, [(0, 100), (60, 110)], [], [], serial: "111"); var (processed, failed) = NewService().Run(importDir, archiveDir); Assert.Equal((1, 0), (processed, failed)); var row = GetWorkout(athlete, phoneId); Assert.Equal("111-", row.FitFileId![..4]); Assert.NotNull(row.FactsJson); Assert.Equal(105, row.AvgHr); // (100+110)/2 Assert.True(File.Exists(Path.Combine(archiveDir, folder, "a.fit"))); } [Fact] public void Import_OutsideWindow_CreatesFitOnlyRow() { var (athlete, folder) = NewAthlete("imp-lone-" + Guid.NewGuid().ToString("N")[..6]); NewPhoneWorkout(athlete, Start.AddHours(3)); // вне ± 15 мин var (importDir, archiveDir) = NewDirs(); var folderPath = Path.Combine(importDir, folder); Directory.CreateDirectory(folderPath); FitParser.WriteTestFile(Path.Combine(folderPath, "b.fit"), Start, [(0, 100)], [], [], serial: "222"); var (processed, failed) = NewService().Run(importDir, archiveDir); Assert.Equal((1, 0), (processed, failed)); var rows = WorkoutRepo().Select(new WorkoutFilter { AthleteId = athlete, DateFrom = DateOnly.FromDateTime(Start).AddDays(-1), DateTo = DateOnly.FromDateTime(Start).AddDays(1), }).ToList(); var fitOnly = rows.Single(w => w.FitFileId != null); Assert.Equal(Start, fitOnly.StartedAt); Assert.Equal("finished", fitOnly.Status); Assert.Equal("normal", fitOnly.SessionKind); Assert.Equal(100, fitOnly.AvgHr); } [Fact] public void Import_SameFileTwice_Dedup() { var (athlete, folder) = NewAthlete("imp-dedup-" + Guid.NewGuid().ToString("N")[..6]); var (importDir, archiveDir) = NewDirs(); var folderPath = Path.Combine(importDir, folder); Directory.CreateDirectory(folderPath); FitParser.WriteTestFile(Path.Combine(folderPath, "c.fit"), Start, [(0, 100)], [], [], serial: "333"); Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); // повтор: тот же файл возвращаем в папку импорта var repeat = Path.Combine(folderPath, "c.fit"); File.Copy(Path.Combine(archiveDir, folder, "c.fit"), repeat); Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); var rows = WorkoutRepo().Select(new WorkoutFilter { AthleteId = athlete, DateFrom = DateOnly.FromDateTime(Start).AddDays(-1), DateTo = DateOnly.FromDateTime(Start).AddDays(1), }).ToList(); Assert.Single(rows.Where(w => w.FitFileId != null)); } // --- spec 03: пакет программы --- [Fact] public void Import_ProgramPackage_FirstAndRepeat() { var (athlete, folder) = NewAthlete("imp-pkg-" + Guid.NewGuid().ToString("N")[..6]); var (importDir, archiveDir) = NewDirs(); var folderPath = Path.Combine(importDir, folder); Directory.CreateDirectory(folderPath); // external_id уникален глобально (З-35) — id пакета свой на прогон var packageId = "pkg-test-" + Guid.NewGuid().ToString("N")[..8]; var package = $$""" {"program_id":"{{packageId}}","version":1,"status":"draft","author":"user", "rationale":"тестовый пакет", "weeks":[{"week":1,"sessions":[{"day":"monday","focus":"push", "exercises":[{"exercise_id":"жим_лёжа_тест_{{packageId}}","sets":[ {"weight_kg":60,"reps":10}]}]}]}]} """; File.WriteAllText(Path.Combine(folderPath, "program-import.json"), package, Encoding.UTF8); var cs = NewCs(); var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs)); Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); var program = new ProgramRepository(cs).Select(athlete).Single(p => p.ExternalId == packageId); var versions1 = store.ListVersions(athlete); Assert.Single(versions1); Assert.Equal("draft", versions1[0].Status); Assert.Equal("user", versions1[0].Author); Assert.Equal("тестовый пакет", versions1[0].Rationale); // отсутствующее упражнение создано (фокус push) var exercise = new ExerciseRepository(cs).Select(new ExerciseFilter { AthleteId = athlete, Name = $"жим_лёжа_тест_{packageId}" }).Single(); Assert.Equal(1, exercise.GroupId); // push // повтор пакета → новая версия Directory.CreateDirectory(folderPath); File.WriteAllText(Path.Combine(folderPath, "program-import.json"), package, Encoding.UTF8); Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); var versions2 = store.ListVersions(athlete); Assert.Equal(2, versions2.Count); Assert.Equal(versions1[0].Version + 1, versions2[1].Version); } // --- TCX/GPX: упрощённый факт (критерий 4) --- [Fact] public void Import_Tcx_SimplifiedFacts() { var (athlete, folder) = NewAthlete("imp-tcx-" + Guid.NewGuid().ToString("N")[..6]); var (importDir, archiveDir) = NewDirs(); var folderPath = Path.Combine(importDir, folder); Directory.CreateDirectory(folderPath); var tcx = """ 2026-10-11T08:00:00Z 1200 120 130 """; File.WriteAllText(Path.Combine(folderPath, "ride.tcx"), tcx); Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); var row = WorkoutRepo().Select(new WorkoutFilter { AthleteId = athlete, DateFrom = new DateOnly(2026, 10, 10), DateTo = new DateOnly(2026, 10, 12), }).Single(w => w.FitFileId != null); Assert.Equal(Start.Date != default ? new DateTime(2026, 10, 11, 8, 0, 0, DateTimeKind.Utc) : default, row.StartedAt); Assert.Equal(125, row.AvgHr); // (120+130)/2 var facts = JsonDocument.Parse(row.FactsJson!).RootElement; Assert.Equal("biking", facts.GetProperty("session").GetProperty("type").GetString()); Assert.Equal(2, facts.GetProperty("records").GetArrayLength()); Assert.Equal(0, facts.GetProperty("sets").GetArrayLength()); } [Fact] public void Import_Gpx_SimplifiedFacts() { var (athlete, folder) = NewAthlete("imp-gpx-" + Guid.NewGuid().ToString("N")[..6]); var (importDir, archiveDir) = NewDirs(); var folderPath = Path.Combine(importDir, folder); Directory.CreateDirectory(folderPath); var gpx = """ прогулка 105 115 """; File.WriteAllText(Path.Combine(folderPath, "walk.gpx"), gpx); Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); var row = WorkoutRepo().Select(new WorkoutFilter { AthleteId = athlete, DateFrom = new DateOnly(2026, 10, 11), DateTo = new DateOnly(2026, 10, 13), }).Single(w => w.FitFileId != null); Assert.Equal(110, row.AvgHr); // (105+115)/2 } // --- spec 04: сбои и изоляция --- [Fact] public void Import_BrokenFile_StaysAndCode2() { var (athlete, folder) = NewAthlete("imp-broken-" + Guid.NewGuid().ToString("N")[..6]); var (importDir, archiveDir) = NewDirs(); var folderPath = Path.Combine(importDir, folder); Directory.CreateDirectory(folderPath); File.WriteAllBytes(Path.Combine(folderPath, "bad.fit"), [1, 2, 3, 4, 5]); FitParser.WriteTestFile(Path.Combine(folderPath, "good.fit"), Start, [(0, 100)], [], [], serial: "444"); var (processed, failed) = NewService().Run(importDir, archiveDir); Assert.Equal(1, processed); Assert.Equal(1, failed); Assert.True(File.Exists(Path.Combine(folderPath, "bad.fit"))); // остался Assert.True(File.Exists(Path.Combine(archiveDir, folder, "good.fit"))); } [Fact] public void Import_UnknownAthleteFolder_Fails() { var (importDir, archiveDir) = NewDirs(); var ghost = Path.Combine(importDir, "нет-такого-атлета"); Directory.CreateDirectory(ghost); File.WriteAllText(Path.Combine(ghost, "program-import.json"), "{}"); var (processed, failed) = NewService().Run(importDir, archiveDir); Assert.Equal((0, 1), (processed, failed)); Assert.True(File.Exists(Path.Combine(ghost, "program-import.json"))); } [Fact] public void Import_RootFile_Skipped() { var (importDir, archiveDir) = NewDirs(); File.WriteAllText(Path.Combine(importDir, "random.txt"), "x"); Assert.Equal((0, 0), NewService().Run(importDir, archiveDir)); Assert.True(File.Exists(Path.Combine(importDir, "random.txt"))); } [Fact] public void Import_Isolation_FitDoesNotMatchForeignWorkout() { var (_, otherFolder) = NewAthlete("imp-other-" + Guid.NewGuid().ToString("N")[..6]); var (athlete, folder) = NewAthlete("imp-iso-" + Guid.NewGuid().ToString("N")[..6]); // чужая тренировка точно в окне NewPhoneWorkout(new AthleteRepository(NewCs()).Select(new AthleteFilter()).First( a => a.Name == otherFolder).Id, Start); var (importDir, archiveDir) = NewDirs(); var folderPath = Path.Combine(importDir, folder); Directory.CreateDirectory(folderPath); FitParser.WriteTestFile(Path.Combine(folderPath, "d.fit"), Start, [(0, 100)], [], [], serial: "555"); Assert.Equal((1, 0), NewService().Run(importDir, archiveDir)); // факт попал в строку своего атлета (только .fit), чужая не тронута var other = new AthleteRepository(NewCs()).Select(new AthleteFilter()).First(a => a.Name == otherFolder); var foreign = WorkoutRepo().Select(new WorkoutFilter { AthleteId = other.Id }).Single(); Assert.Null(foreign.FitFileId); var mine = WorkoutRepo().Select(new WorkoutFilter { AthleteId = athlete }).Single(w => w.FitFileId != null); Assert.NotEqual(foreign.Id, mine.Id); } }