387 lines
17 KiB
C#
387 lines
17 KiB
C#
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);
|
||
}
|
||
}
|