Skip to content

Commit e8f9bdb

Browse files
committed
fix: add 2-day buffer to sync boundary and force_full_sync flag
- Change sync splice from 90-day to 88-day cutoff (2-day buffer) - Truncate both existing and new data at same boundary for clean splice - Add force_full_sync boolean flag to source_data table - Add GetForceFullSyncAsync() to check flag and trigger data clear - Update MeasurementSyncService to check flag before sync - Add test for force_full_sync functionality - All 334 tests pass
1 parent b4b169e commit e8f9bdb

7 files changed

Lines changed: 154 additions & 22 deletions

File tree

apps/api/TrendWeight.Tests/Features/Measurements/Services/MeasurementSyncServiceTests.cs

Lines changed: 93 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,8 @@ public async Task RefreshProviderAsync_WithExistingDataAndSyncWindow_MergesCorre
785785
var userId = Guid.NewGuid();
786786
var provider = "withings";
787787
var lastSyncTime = DateTime.UtcNow.AddDays(-1);
788-
var syncStartDate = lastSyncTime.AddDays(-90); // 90 days before last sync
788+
var syncStartDate = lastSyncTime.AddDays(-90); // Fetch starts 90 days before last sync
789+
var cutoffDate = syncStartDate.AddDays(2); // Splice at 88 days before last sync (2-day buffer)
789790

790791
// Existing data from previous sync (has both old and recent measurements)
791792
var existingSourceData = new List<SourceData>
@@ -796,23 +797,23 @@ public async Task RefreshProviderAsync_WithExistingDataAndSyncWindow_MergesCorre
796797
LastUpdate = lastSyncTime,
797798
Measurements = new List<RawMeasurement>
798799
{
799-
// Old measurements (before sync window)
800-
CreateTestRawMeasurement(syncStartDate.AddDays(-10).ToString("yyyy-MM-dd"), 65.0m),
801-
CreateTestRawMeasurement(syncStartDate.AddDays(-5).ToString("yyyy-MM-dd"), 66.0m),
802-
// Recent measurements (within sync window)
803-
CreateTestRawMeasurement(syncStartDate.AddDays(5).ToString("yyyy-MM-dd"), 70.0m),
804-
CreateTestRawMeasurement(syncStartDate.AddDays(10).ToString("yyyy-MM-dd"), 71.0m),
805-
CreateTestRawMeasurement(syncStartDate.AddDays(15).ToString("yyyy-MM-dd"), 72.0m)
800+
// Old measurements (before cutoff at day -88, should be preserved)
801+
CreateTestRawMeasurement(syncStartDate.AddDays(-10).ToString("yyyy-MM-dd"), 65.0m), // day -100
802+
CreateTestRawMeasurement(syncStartDate.AddDays(-5).ToString("yyyy-MM-dd"), 66.0m), // day -95
803+
// Recent measurements (after cutoff, should be replaced by provider data)
804+
CreateTestRawMeasurement(syncStartDate.AddDays(5).ToString("yyyy-MM-dd"), 70.0m), // day -85
805+
CreateTestRawMeasurement(syncStartDate.AddDays(10).ToString("yyyy-MM-dd"), 71.0m), // day -80
806+
CreateTestRawMeasurement(syncStartDate.AddDays(15).ToString("yyyy-MM-dd"), 72.0m) // day -75
806807
}
807808
}
808809
};
809810

810-
// New measurements from provider (only has data within sync window)
811+
// New measurements from provider (fetched from day -90, truncated to day -88 onwards)
811812
var newMeasurements = new List<RawMeasurement>
812813
{
813-
CreateTestRawMeasurement(syncStartDate.AddDays(5).ToString("yyyy-MM-dd"), 70.5m), // Updated weight
814-
CreateTestRawMeasurement(syncStartDate.AddDays(12).ToString("yyyy-MM-dd"), 71.5m), // New measurement
815-
// Note: measurements at day 10 and 15 are missing (user deleted them)
814+
CreateTestRawMeasurement(syncStartDate.AddDays(5).ToString("yyyy-MM-dd"), 70.5m), // day -85, updated weight
815+
CreateTestRawMeasurement(syncStartDate.AddDays(12).ToString("yyyy-MM-dd"), 71.5m), // day -78, new measurement
816+
// Note: measurements at days -80 and -75 are missing (user deleted them upstream)
816817
};
817818

818819
var providerService = new Mock<IProviderService>();
@@ -939,6 +940,79 @@ public async Task RefreshProviderAsync_WithFullSync_ReplacesAllData()
939940
It.Is<List<SourceData>>(sd => VerifyFullSyncReplacement(sd, provider, newMeasurements))), Times.Once);
940941
}
941942

943+
[Fact]
944+
public async Task GetMeasurementsForUserAsync_WithForceFullSyncFlag_ClearsDataAndPerformsFullSync()
945+
{
946+
// Arrange
947+
var userId = Guid.NewGuid();
948+
var provider = "fitbit";
949+
950+
// Existing data exists
951+
var existingSourceData = new List<SourceData>
952+
{
953+
new SourceData
954+
{
955+
Source = provider,
956+
LastUpdate = DateTime.UtcNow.AddDays(-30),
957+
Measurements = new List<RawMeasurement>
958+
{
959+
CreateTestRawMeasurement("2024-01-01", 70.0m),
960+
CreateTestRawMeasurement("2024-01-02", 71.0m)
961+
}
962+
}
963+
};
964+
965+
// New measurements from full sync
966+
var newMeasurements = new List<RawMeasurement>
967+
{
968+
CreateTestRawMeasurement("2024-01-01", 70.5m), // Updated
969+
CreateTestRawMeasurement("2024-01-02", 71.5m), // Updated
970+
CreateTestRawMeasurement("2024-01-03", 72.0m) // New
971+
};
972+
973+
var providerService = new Mock<IProviderService>();
974+
// Verify that startDate is null (full sync) after clearing data
975+
providerService.Setup(x => x.SyncMeasurementsAsync(userId, true, null))
976+
.ReturnsAsync(new ProviderSyncResult
977+
{
978+
Provider = provider,
979+
Success = true,
980+
Measurements = newMeasurements
981+
});
982+
983+
_providerIntegrationServiceMock.Setup(x => x.GetProviderService(provider))
984+
.Returns(providerService.Object);
985+
986+
// Mock force_full_sync flag to return true
987+
_sourceDataServiceMock.Setup(x => x.GetForceFullSyncAsync(userId, provider))
988+
.ReturnsAsync(true);
989+
990+
// Mock GetLastSyncTimeAsync to return null (simulating cleared data after ClearProviderDataAsync is called)
991+
_sourceDataServiceMock.Setup(x => x.GetLastSyncTimeAsync(userId, provider))
992+
.ReturnsAsync((DateTime?)null);
993+
994+
_sourceDataServiceMock.Setup(x => x.GetSourceDataAsync(userId, new List<string> { provider }))
995+
.ReturnsAsync(existingSourceData);
996+
997+
// Mock ClearSourceDataAsync to simulate data being cleared
998+
_sourceDataServiceMock.Setup(x => x.ClearSourceDataAsync(userId, provider))
999+
.Returns(Task.CompletedTask);
1000+
1001+
// Act
1002+
var result = await _sut.GetMeasurementsForUserAsync(userId, new List<string> { provider }, true);
1003+
1004+
// Assert
1005+
// Verify ClearProviderDataAsync was called (this is called on MeasurementSyncService, not the mock)
1006+
// We verify this indirectly by checking that a full sync was performed (startDate = null)
1007+
providerService.Verify(x => x.SyncMeasurementsAsync(userId, true, null), Times.Once,
1008+
"Should perform full sync (startDate = null) after force_full_sync flag is detected");
1009+
1010+
// Verify data was updated
1011+
_sourceDataServiceMock.Verify(x => x.UpdateSourceDataAsync(
1012+
userId,
1013+
It.Is<List<SourceData>>(sd => sd.Count == 1 && sd[0].Source == provider)), Times.Once);
1014+
}
1015+
9421016
#endregion
9431017

9441018
#region Private Helper Methods
@@ -981,14 +1055,17 @@ private static bool VerifyMergedData(List<SourceData> sd, string provider, DateT
9811055
return false;
9821056

9831057
var measurements = sd[0].Measurements!; // We already checked it's not null
984-
return measurements.Count == 4 && // 2 old (preserved) + 2 new (from provider)
985-
// Old measurements preserved
1058+
// Cutoff is at syncStartDate + 2 days (88 days before last sync)
1059+
// Old measurements (before cutoff): preserved
1060+
// New measurements (after cutoff): from provider
1061+
return measurements.Count == 4 && // 2 old (preserved before day -88) + 2 new (from provider after day -88)
1062+
// Old measurements preserved (days -100, -95 are before cutoff at day -88)
9861063
measurements.Any(m => m.Date == syncStartDate.AddDays(-10).ToString("yyyy-MM-dd") && m.Weight == 65.0m) &&
9871064
measurements.Any(m => m.Date == syncStartDate.AddDays(-5).ToString("yyyy-MM-dd") && m.Weight == 66.0m) &&
988-
// New measurements from provider
1065+
// New measurements from provider (days -85, -78 are after cutoff at day -88)
9891066
measurements.Any(m => m.Date == syncStartDate.AddDays(5).ToString("yyyy-MM-dd") && m.Weight == 70.5m) &&
9901067
measurements.Any(m => m.Date == syncStartDate.AddDays(12).ToString("yyyy-MM-dd") && m.Weight == 71.5m) &&
991-
// Deleted measurements are gone
1068+
// Deleted measurements are gone (days -80, -75 were in old data but not in provider data)
9921069
!measurements.Any(m => m.Date == syncStartDate.AddDays(10).ToString("yyyy-MM-dd")) &&
9931070
!measurements.Any(m => m.Date == syncStartDate.AddDays(15).ToString("yyyy-MM-dd"));
9941071
}

apps/api/TrendWeight/Features/Measurements/ISourceDataService.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ public interface ISourceDataService
2828
/// <returns>Last sync DateTime in UTC, or null if no data exists</returns>
2929
Task<DateTime?> GetLastSyncTimeAsync(Guid userId, string provider);
3030

31+
/// <summary>
32+
/// Get the force_full_sync flag for a specific provider
33+
/// </summary>
34+
/// <param name="userId">User's Supabase UID</param>
35+
/// <param name="provider">Provider name (e.g., "withings", "fitbit")</param>
36+
/// <returns>True if force_full_sync is set, false otherwise</returns>
37+
Task<bool> GetForceFullSyncAsync(Guid userId, string provider);
38+
3139
/// <summary>
3240
/// Clears source data for a user
3341
/// </summary>

apps/api/TrendWeight/Features/Measurements/MeasurementSyncService.cs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ public async Task<MeasurementsResult> GetMeasurementsForUserAsync(
5757
// Check each provider's last sync time and resync flag
5858
foreach (var provider in activeProviders)
5959
{
60+
// Check force_full_sync flag - if set, clear data to trigger full resync
61+
var forceFullSync = await _sourceDataService.GetForceFullSyncAsync(userId, provider);
62+
if (forceFullSync)
63+
{
64+
_logger.LogInformation("Force full sync set for user {UserId} provider {Provider}, clearing data", userId, provider);
65+
await ClearProviderDataAsync(userId, provider);
66+
}
67+
6068
// Check last sync time for this provider
6169
var lastSync = await _sourceDataService.GetLastSyncTimeAsync(userId, provider);
6270
var needsRefresh = lastSync == null || (now - lastSync.Value).TotalSeconds > _cacheDurationSeconds;
@@ -155,12 +163,12 @@ private async Task<ProviderSyncResult> RefreshProviderAsync(Guid userId, string
155163

156164
// Calculate start date for sync
157165
DateTime? startDate = null;
158-
// For regular refresh, fetch from 90 days before last sync
166+
// For regular refresh, fetch from 90 days before last sync (with 2-day buffer to avoid boundary issues)
159167
var lastSyncTime = await _sourceDataService.GetLastSyncTimeAsync(userId, provider);
160168
if (lastSyncTime.HasValue)
161169
{
162170
startDate = lastSyncTime.Value.AddDays(-90);
163-
_logger.LogDebug("Fetching {Provider} measurements from {StartDate} (90 days before last sync)",
171+
_logger.LogDebug("Fetching {Provider} measurements from {StartDate} (90 days before last sync with 2-day buffer)",
164172
provider, startDate.Value.ToString("o"));
165173
}
166174
else
@@ -194,15 +202,25 @@ await _progressReporter.ReportProviderProgressAsync(
194202
if (existingProviderData?.Measurements != null && startDate.HasValue)
195203
{
196204
// We have existing data and a sync window - merge them
197-
var cutoffDate = startDate.Value.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
205+
// Cutoff is 2 days after fetch start (88 days before last sync) to create a buffer zone
206+
// that handles timezone interpretation differences and API boundary quirks
207+
var cutoffDate = startDate.Value.AddDays(2).ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
208+
209+
_logger.LogDebug("Merging {Provider} data with cutoff date {CutoffDate} (fetch started at {FetchStart})",
210+
provider, cutoffDate, startDate.Value.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture));
198211

199212
// Keep only measurements before the cutoff date from existing data
200213
var existingMeasurementsToKeep = existingProviderData.Measurements
201214
.Where(m => string.Compare(m.Date, cutoffDate, StringComparison.Ordinal) < 0)
202215
.ToList();
203216

204-
// Combine: new measurements (truth for sync window) + older kept measurements
205-
mergedMeasurements = result.Measurements.Concat(existingMeasurementsToKeep).ToList();
217+
// Keep only measurements on or after the cutoff date from new data (discard buffer zone)
218+
var newMeasurementsToUse = result.Measurements
219+
.Where(m => string.Compare(m.Date, cutoffDate, StringComparison.Ordinal) >= 0)
220+
.ToList();
221+
222+
// Combine: truncated new measurements (truth from cutoff forward) + older kept measurements
223+
mergedMeasurements = newMeasurementsToUse.Concat(existingMeasurementsToKeep).ToList();
206224
}
207225
else
208226
{

apps/api/TrendWeight/Features/Measurements/SourceDataService.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,21 @@ public async Task UpdateSourceDataAsync(Guid userId, List<SourceData> data)
234234
}
235235
}
236236

237+
/// <inheritdoc />
238+
public async Task<bool> GetForceFullSyncAsync(Guid userId, string provider)
239+
{
240+
try
241+
{
242+
var dbSourceData = await GetOrFetchSourceDataAsync(userId, provider);
243+
return dbSourceData?.ForceFullSync ?? false;
244+
}
245+
catch (Exception ex)
246+
{
247+
_logger.LogError(ex, "Error getting force_full_sync for user {UserId} provider {Provider}", userId, provider);
248+
return false;
249+
}
250+
}
251+
237252
/// <inheritdoc />
238253
public async Task ClearSourceDataAsync(Guid userId, string? provider = null)
239254
{

apps/api/TrendWeight/Infrastructure/DataAccess/Models/DbSourceData.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ public class DbSourceData : BaseModel
2121
[Column("last_sync")]
2222
public string? LastSync { get; set; }
2323

24+
[Column("force_full_sync")]
25+
public bool ForceFullSync { get; set; } = false;
26+
2427
[Column("updated_at")]
2528
public string UpdatedAt { get; set; } = string.Empty;
2629
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Add force_full_sync flag to source_data table
2+
-- This flag allows triggering a full resync for specific provider data
3+
-- by clearing existing data before the next sync
4+
5+
ALTER TABLE public.source_data
6+
ADD COLUMN IF NOT EXISTS force_full_sync BOOLEAN DEFAULT FALSE;
7+
8+
-- Add comment explaining the purpose
9+
COMMENT ON COLUMN public.source_data.force_full_sync IS
10+
'When true, triggers deletion of this source_data row before next sync, causing a full resync. Auto-clears when new data is written.';

apps/api/TrendWeight/supabase/schema.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ CREATE TABLE IF NOT EXISTS public.source_data (
5050
provider VARCHAR NOT NULL,
5151
measurements JSONB NOT NULL DEFAULT '[]'::jsonb,
5252
last_sync TEXT,
53+
force_full_sync BOOLEAN DEFAULT FALSE,
5354
updated_at TEXT DEFAULT now(),
5455
CONSTRAINT source_data_pkey PRIMARY KEY (uid, provider),
55-
CONSTRAINT source_data_uid_fkey FOREIGN KEY (uid)
56+
CONSTRAINT source_data_uid_fkey FOREIGN KEY (uid)
5657
REFERENCES public.profiles(uid) ON DELETE CASCADE
5758
);
5859

0 commit comments

Comments
 (0)