using System.Net; using KronaStaffTerminal.Models; using KronaStaffTerminal.Services; using Xunit; namespace KronaStaffTerminal.Tests; public class AttendanceSyncTests { private sealed class MockHttpMessageHandler : HttpMessageHandler { public Func HandlerFunc { get; set; } = _ => new HttpResponseMessage(HttpStatusCode.OK); public List CapturedRequests { get; } = new(); protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { CapturedRequests.Add(request); return Task.FromResult(HandlerFunc(request)); } } private static TerminalState CreateConfiguredState() { return new TerminalState { Settings = new TerminalSettings { StaffServerUrl = "https://staff.krona.internal:8453", StaffServerToken = "test_token_secret_123", TerminalDeviceId = "TERM-TEST-01" } }; } [Fact] public async Task SyncPending_With200Ok_MarksRecordsAsSyncedAndSendsHeaders() { var mockHandler = new MockHttpMessageHandler { HandlerFunc = _ => new HttpResponseMessage(HttpStatusCode.OK) }; var httpClient = new HttpClient(mockHandler); var syncService = new AttendanceSyncService(httpClient); var state = CreateConfiguredState(); var record1 = new AttendanceRecord { Id = UlidGenerator.New(), EmployeeId = "emp-10", EventType = "Приход", OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-5), ServerSynced = false }; var record2 = new AttendanceRecord { Id = UlidGenerator.New(), EmployeeId = "emp-10", EventType = "Уход", OccurredAtUtc = DateTimeOffset.UtcNow, ServerSynced = false }; state.Attendance.Add(record1); state.Attendance.Add(record2); var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken); Assert.Equal(2, result.ProcessedCount); Assert.Equal(2, result.SuccessCount); Assert.Equal(0, result.FailedCount); Assert.True(record1.ServerSynced); Assert.True(record2.ServerSynced); // Verify headers Assert.Equal(2, mockHandler.CapturedRequests.Count); var req = mockHandler.CapturedRequests[0]; Assert.Equal("Bearer test_token_secret_123", req.Headers.Authorization?.ToString()); Assert.True(req.Headers.Contains("Idempotency-Key")); Assert.True(req.Headers.Contains("X-Request-Id")); } [Fact] public async Task SyncPending_With409Conflict_TreatedAsIdempotentSuccess() { var mockHandler = new MockHttpMessageHandler { HandlerFunc = _ => new HttpResponseMessage(HttpStatusCode.Conflict) }; var httpClient = new HttpClient(mockHandler); var syncService = new AttendanceSyncService(httpClient); var state = CreateConfiguredState(); var record = new AttendanceRecord { Id = UlidGenerator.New(), EmployeeId = "emp-15", EventType = "Приход", OccurredAtUtc = DateTimeOffset.UtcNow, ServerSynced = false }; state.Attendance.Add(record); var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken); Assert.Equal(1, result.ProcessedCount); Assert.Equal(1, result.SuccessCount); Assert.Equal(0, result.FailedCount); Assert.True(record.ServerSynced); Assert.Null(record.ServerSyncError); } [Fact] public async Task SyncPending_With500Error_RetainsRecordForRetry() { var mockHandler = new MockHttpMessageHandler { HandlerFunc = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError) }; var httpClient = new HttpClient(mockHandler); var syncService = new AttendanceSyncService(httpClient); var state = CreateConfiguredState(); var record = new AttendanceRecord { Id = UlidGenerator.New(), EmployeeId = "emp-20", EventType = "Приход", OccurredAtUtc = DateTimeOffset.UtcNow, ServerSynced = false }; state.Attendance.Add(record); var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken); Assert.Equal(1, result.ProcessedCount); Assert.Equal(0, result.SuccessCount); Assert.Equal(1, result.FailedCount); Assert.False(record.ServerSynced); Assert.Equal("HTTP 500", record.ServerSyncError); } [Fact] public async Task SyncPending_WithNetworkException_HaltsCurrentCycle() { var mockHandler = new MockHttpMessageHandler { HandlerFunc = _ => throw new HttpRequestException("Connection refused") }; var httpClient = new HttpClient(mockHandler); var syncService = new AttendanceSyncService(httpClient); var state = CreateConfiguredState(); var record1 = new AttendanceRecord { Id = UlidGenerator.New(), EmployeeId = "emp-1", EventType = "Приход", OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-2), ServerSynced = false }; var record2 = new AttendanceRecord { Id = UlidGenerator.New(), EmployeeId = "emp-2", EventType = "Приход", OccurredAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1), ServerSynced = false }; state.Attendance.Add(record1); state.Attendance.Add(record2); var result = await syncService.SyncPendingAsync(state, TestContext.Current.CancellationToken); Assert.Equal(2, result.ProcessedCount); Assert.Equal(1, result.FailedCount); Assert.True(result.HasNetworkError); Assert.False(record1.ServerSynced); Assert.False(record2.ServerSynced); // Only first was attempted because loop broke on network drop Assert.Single(mockHandler.CapturedRequests); } }