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.Fetch; using Server.WatchBody.Import; using Xunit; namespace Server.WatchBody.Web.Tests; /// /// Интеграционные тесты m07 (specs 01–03) против docker watchbody-pg /// с фейковым Garmin (FakeGarminHandler); .fit — энкодер SDK. /// [Collection("web")] public class FitFetcherTests(TestApp app) { private static readonly DateTime Start = new(2026, 10, 15, 18, 0, 0, DateTimeKind.Utc); private ConnectionString NewCs() => new() { Type = ConnectionStringType.Postgres, Value = app.Config.DbConnectionString, }; private int NewAthlete(string name) { var cs = NewCs(); var athleteId = new AthleteRepository(cs).Insert(new Athlete { Name = name, ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), }); return athleteId; } private static (string Dir, string Base) NewEnv() { var dir = Path.Combine(Path.GetTempPath(), "wb-fetch-" + Guid.NewGuid().ToString("N")[..8]); Directory.CreateDirectory(dir); return (dir, "http://fake-garmin.test"); } private static byte[] StrengthFit() => FitBytes(Start, "123456-20261015T180000Z"); private static byte[] FitBytes(DateTime start, string _) // serial задаётся энкодером { var path = Path.Combine(Path.GetTempPath(), "wb-fit-" + Guid.NewGuid().ToString("N")[..8] + ".fit"); FitParser.WriteTestFile(path, start, records: [(0, 100), (60, 110)], laps: [(0, 0, 300)], sets: [(10, 80.0m)], serial: "777"); var bytes = File.ReadAllBytes(path); File.Delete(path); return bytes; } private FitFetcher NewFetcher(string dir, FakeGarminHandler handler, string athleteName) => new(NewCs(), new GarminClient("owner@example.com", "secret", "http://fake-garmin.test", handler), dir, athleteName); // --- spec 02: цикл --- [Fact] public void Fetch_StrengthOnly_DownloadedAndImported() { var athlete = NewAthlete("f7-" + Guid.NewGuid().ToString("N")[..8]); var (dir, _) = NewEnv(); var handler = new FakeGarminHandler { Activities = [ new GarminActivity(1001, Start, "strength_training"), new GarminActivity(1002, Start.AddHours(-2), "cardio"), // не силовая ], FitFiles = { [1001] = StrengthFit() }, }; var (downloaded, skipped, failed) = NewFetcher(dir, handler, athleteName(athlete)).FetchNew(); Assert.Equal((1, 0, 0), (downloaded, skipped, failed)); Assert.Equal(1, handler.DownloadCalls); // cardio не скачивается var row = new WorkoutRepository(NewCs()).Select(new WorkoutFilter { AthleteId = athlete, DateFrom = new DateOnly(2026, 10, 14), DateTo = new DateOnly(2026, 10, 16), }).Single(w => w.FitFileId != null); Assert.NotNull(row.FactsJson); Assert.Equal(105, row.AvgHr); Assert.True(File.Exists(Path.Combine(dir, "1001.fit"))); // кэш // повторный fetch — всё в кэше, скачиваний нет, дублей нет var second = NewFetcher(dir, handler, athleteName(athlete)).FetchNew(); Assert.Equal((0, 1, 0), second); Assert.Equal(1, handler.DownloadCalls); } [Fact] public void Fetch_DuplicateFitFileId_FileDeletedNoSecondRow() { var athlete = NewAthlete("f7-dup-" + Guid.NewGuid().ToString("N")[..8]); var (dir, _) = NewEnv(); var handler = new FakeGarminHandler { Activities = { new GarminActivity(2001, Start, "strength_training") }, FitFiles = { [2001] = StrengthFit() }, }; Assert.Equal((1, 0, 0), NewFetcher(dir, handler, athleteName(athlete)).FetchNew()); // тот же .fit под другим activityId (переименование в Garmin) handler.Activities.Add(new GarminActivity(2002, Start, "strength_training")); handler.FitFiles[2002] = StrengthFit(); var (downloaded, skipped, failed) = NewFetcher(dir, handler, athleteName(athlete)).FetchNew(); // 2001 — кэш (рубеж 1), 2002 — дубль fit_file_id (рубеж 2) Assert.Equal((0, 2, 0), (downloaded, skipped, failed)); Assert.False(File.Exists(Path.Combine(dir, "2002.fit"))); // дубль не кэшируется var rows = new WorkoutRepository(NewCs()).Select(new WorkoutFilter { AthleteId = athlete, DateFrom = new DateOnly(2026, 10, 14), DateTo = new DateOnly(2026, 10, 16), }).Count(w => w.FitFileId != null); Assert.Equal(1, rows); // второй строки нет } [Fact] public void Fetch_BrokenDownload_MarkedBad_Continues() { var athlete = NewAthlete("f7-bad-" + Guid.NewGuid().ToString("N")[..8]); var (dir, _) = NewEnv(); var handler = new FakeGarminHandler { Activities = [ new GarminActivity(3001, Start, "strength_training"), new GarminActivity(3002, Start.AddHours(-1), "strength_training"), ], FitFiles = { [3001] = [1, 2, 3], [3002] = StrengthFit() }, }; var (downloaded, skipped, failed) = NewFetcher(dir, handler, athleteName(athlete)).FetchNew(); Assert.Equal((1, 0, 1), (downloaded, skipped, failed)); Assert.True(File.Exists(Path.Combine(dir, "3001.fit.bad"))); Assert.True(File.Exists(Path.Combine(dir, "3002.fit"))); } [Fact] public void Fetch_UnknownAthlete_Throws() { var (dir, _) = NewEnv(); var handler = new FakeGarminHandler { Activities = { new GarminActivity(4001, Start, "strength_training") }, FitFiles = { [4001] = StrengthFit() }, }; var fetcher = NewFetcher(dir, handler, "нет-такого-атлета"); Assert.Throws(() => fetcher.FetchNew()); Assert.Equal(0, handler.LoginCalls); // атлет проверяется до сети } [Fact] public void Fetch_AuthFailure_ThrowsWithoutPassword() { var athlete = NewAthlete("f7-auth-" + Guid.NewGuid().ToString("N")[..8]); var (dir, _) = NewEnv(); var handler = new FakeGarminHandler { FailLogin = true }; var fetcher = NewFetcher(dir, handler, athleteName(athlete)); var exception = Assert.Throws(() => fetcher.FetchNew()); Assert.DoesNotContain("secret", exception.Message); // 🛡4: пароль не в тексте Assert.Equal(1, handler.LoginCalls); } private string athleteName(int athleteId) => new AthleteRepository(NewCs()).Select(new AthleteFilter()) .First(a => a.Id == athleteId).Name; }