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

387 lines
17 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.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;
/// <summary>
/// Интеграционные тесты m06 (specs 01–04) против docker watchbody-pg.
/// .fit-фикстуры генерируются энкодером SDK (FitParser.WriteTestFile).
/// </summary>
[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);
}
/// <summary>Каталог импорта на время теста; атлет — подпапка.</summary>
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());
/// <summary>Телефонная тренировка для сопоставления.</summary>
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<Exception>(() => 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 = """
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities><Activity Sport="Biking">
<Id>2026-10-11T08:00:00Z</Id>
<Laps><Lap StartTime="2026-10-11T08:00:00Z"><TotalTimeSeconds>1200</TotalTimeSeconds></Lap></Laps>
<Tracks><Track>
<Trackpoint><Time>2026-10-11T08:00:00Z</Time><HeartRateBpm><Value>120</Value></HeartRateBpm></Trackpoint>
<Trackpoint><Time>2026-10-11T08:05:00Z</Time><HeartRateBpm><Value>130</Value></HeartRateBpm></Trackpoint>
</Track></Tracks>
</Activity></Activities>
</TrainingCenterDatabase>
""";
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>прогулка</name>
<trkseg>
<trkpt lat="55.7" lon="37.6"><time>2026-10-12T09:00:00Z</time>
<extensions><gpxtpx:TrackPointExtension xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"><gpxtpx:hr>105</gpxtpx:hr></gpxtpx:TrackPointExtension></extensions>
</trkpt>
<trkpt lat="55.8" lon="37.7"><time>2026-10-12T09:10:00Z</time>
<extensions><gpxtpx:TrackPointExtension xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"><gpxtpx:hr>115</gpxtpx:hr></gpxtpx:TrackPointExtension></extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
""";
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);
}
}