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
""";
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);
}
}