Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 122 additions & 6 deletions IPCamLapse.Tests/CaptureScheduleServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ namespace IPCamLapse.Tests;

public sealed class CaptureScheduleServiceTests
{
private readonly CaptureScheduleService _service = new();
private static readonly TimeZoneInfo TestZone = CreateTestZone();
private readonly CaptureScheduleService _service = new(TestZone);

[Fact]
public void FutureOneTimeScheduleWaitsForStart()
Expand All @@ -27,7 +28,7 @@ public void FutureOneTimeScheduleWaitsForStart()
public void DailyWindowReportsNextLocalStart()
{
var localNow = new DateTime(2026, 8, 27, 18, 0, 0, DateTimeKind.Unspecified);
var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TimeZoneInfo.Local);
var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TestZone);

var result = _service.GetAvailability(new CaptureSchedule
{
Expand All @@ -37,15 +38,15 @@ public void DailyWindowReportsNextLocalStart()
}, utcNow);

Assert.False(result.Active);
var expected = TimeZoneInfo.ConvertTimeToUtc(localNow.Date.AddDays(1).AddHours(7), TimeZoneInfo.Local);
var expected = TimeZoneInfo.ConvertTimeToUtc(localNow.Date.AddDays(1).AddHours(7), TestZone);
Assert.Equal(expected, result.NextStartUtc);
}

[Fact]
public void OvernightDailyWindowIncludesEarlyMorning()
{
var localNow = new DateTime(2026, 8, 27, 2, 0, 0, DateTimeKind.Unspecified);
var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TimeZoneInfo.Local);
var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TestZone);

var result = _service.GetAvailability(new CaptureSchedule
{
Expand All @@ -61,7 +62,7 @@ public void OvernightDailyWindowIncludesEarlyMorning()
public void WeeklyWindowReportsTheNextSelectedDay()
{
var localNow = new DateTime(2026, 8, 27, 12, 0, 0, DateTimeKind.Unspecified);
var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TimeZoneInfo.Local);
var utcNow = TimeZoneInfo.ConvertTimeToUtc(localNow, TestZone);

var result = _service.GetAvailability(new CaptureSchedule
{
Expand All @@ -73,6 +74,121 @@ public void WeeklyWindowReportsTheNextSelectedDay()

Assert.False(result.Active);
var nextMonday = new DateTime(2026, 8, 31, 8, 0, 0, DateTimeKind.Unspecified);
Assert.Equal(TimeZoneInfo.ConvertTimeToUtc(nextMonday, TimeZoneInfo.Local), result.NextStartUtc);
Assert.Equal(TimeZoneInfo.ConvertTimeToUtc(nextMonday, TestZone), result.NextStartUtc);
}

[Fact]
public void SpringForwardWallTimeMovesForwardByTheDstGap()
{
var invalid = new DateTime(2026, 3, 8, 2, 30, 0, DateTimeKind.Unspecified);

var result = _service.ConvertWallTimeToUtc(invalid);

Assert.Equal(new DateTime(2026, 3, 8, 7, 30, 0, DateTimeKind.Utc), result);
}

[Fact]
public void AmbiguousOneTimeStartChoosesEarlierUtcOccurrence()
{
var repeated = new DateTime(2026, 11, 1, 1, 30, 0, DateTimeKind.Unspecified);

var result = _service.ConvertWallTimeToUtc(repeated);

Assert.Equal(new DateTime(2026, 11, 1, 5, 30, 0, DateTimeKind.Utc), result);
}

[Theory]
[InlineData(5)]
[InlineData(6)]
public void BothRepeatedHourInstantsAreInsideRecurringWindow(int utcHour)
{
var result = _service.GetAvailability(new CaptureSchedule
{
Frequency = ScheduleFrequency.Daily,
WindowStartLocal = TimeSpan.FromHours(1),
WindowEndLocal = TimeSpan.FromHours(2)
}, new DateTime(2026, 11, 1, utcHour, 30, 0, DateTimeKind.Utc));

Assert.True(result.Active);
}

[Fact]
public void SecondFallBackHourSelectsItsOwnFutureRecurringStart()
{
var result = _service.GetAvailability(new CaptureSchedule
{
Frequency = ScheduleFrequency.Daily,
WindowStartLocal = TimeSpan.FromHours(1.5),
WindowEndLocal = TimeSpan.FromHours(1.75)
}, new DateTime(2026, 11, 1, 6, 20, 0, DateTimeKind.Utc));

Assert.False(result.Active);
Assert.Equal(new DateTime(2026, 11, 1, 6, 30, 0, DateTimeKind.Utc), result.NextStartUtc);
}

[Theory]
[InlineData(ScheduleFrequency.Daily)]
[InlineData(ScheduleFrequency.Weekly)]
public void WindowStartingBeforeRepeatedHourIncludesSecondOccurrence(ScheduleFrequency frequency)
{
var result = _service.GetAvailability(new CaptureSchedule
{
Frequency = frequency,
WeeklyDay = DayOfWeek.Sunday,
WindowStartLocal = TimeSpan.FromMinutes(30),
WindowEndLocal = TimeSpan.FromHours(1.25)
}, new DateTime(2026, 11, 1, 6, 10, 0, DateTimeKind.Utc));

Assert.True(result.Active);
}

[Fact]
public void SpringForwardGapDoesNotActivateBeforeAdjustedStart()
{
var result = _service.GetAvailability(new CaptureSchedule
{
Frequency = ScheduleFrequency.Daily,
WindowStartLocal = TimeSpan.FromHours(2.5),
WindowEndLocal = TimeSpan.FromHours(4)
}, new DateTime(2026, 3, 8, 7, 10, 0, DateTimeKind.Utc));

Assert.False(result.Active);
Assert.Equal(new DateTime(2026, 3, 8, 7, 30, 0, DateTimeKind.Utc), result.NextStartUtc);
}

[Fact]
public void UtcZoneRemainsDeterministic()
{
var service = new CaptureScheduleService(TimeZoneInfo.Utc);
var wallTime = new DateTime(2026, 6, 1, 12, 15, 0, DateTimeKind.Unspecified);

Assert.Equal(
new DateTime(2026, 6, 1, 12, 15, 0, DateTimeKind.Utc),
service.ConvertWallTimeToUtc(wallTime));
}

private static TimeZoneInfo CreateTestZone()
{
var daylight = TimeZoneInfo.AdjustmentRule.CreateAdjustmentRule(
new DateTime(2020, 1, 1),
new DateTime(2030, 12, 31),
TimeSpan.FromHours(1),
TimeZoneInfo.TransitionTime.CreateFloatingDateRule(
new DateTime(1, 1, 1, 2, 0, 0),
3,
2,
DayOfWeek.Sunday),
TimeZoneInfo.TransitionTime.CreateFloatingDateRule(
new DateTime(1, 1, 1, 2, 0, 0),
11,
1,
DayOfWeek.Sunday));
return TimeZoneInfo.CreateCustomTimeZone(
"IPCamLapse-Test-Eastern",
TimeSpan.FromHours(-5),
"Test Eastern",
"Test Standard",
"Test Daylight",
[daylight]);
}
}
63 changes: 63 additions & 0 deletions IPCamLapse.Tests/TimeZonePageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace IPCamLapse.Tests;

public sealed class TimeZonePageTests
{
[Theory]
[InlineData("/Sessions/Create")]
[InlineData("/System")]
public async Task PagesShowInjectedStartupTimeZoneAndCurrentOffset(string path)
{
var zone = TimeZoneInfo.CreateCustomTimeZone(
"IPCamLapse-Test-Zone",
TimeSpan.FromHours(3),
"Test Zone",
"Test Zone");
var now = new DateTimeOffset(2026, 9, 8, 12, 0, 0, TimeSpan.Zero);
await using var factory = new TimeZoneFactory(zone, new FixedTimeProvider(now));
using var client = factory.CreateClient();

var response = await client.GetAsync(path);
response.EnsureSuccessStatusCode();
var html = await response.Content.ReadAsStringAsync();

Assert.Contains("IPCamLapse-Test-Zone", html, StringComparison.Ordinal);
Assert.Contains("UTC+03:00", html, StringComparison.Ordinal);
}

private sealed class TimeZoneFactory : WebApplicationFactory<Program>
{
private readonly TimeZoneInfo _timeZone;
private readonly TimeProvider _timeProvider;

public TimeZoneFactory(TimeZoneInfo timeZone, TimeProvider timeProvider)
{
_timeZone = timeZone;
_timeProvider = timeProvider;
}

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.RemoveAll<TimeZoneInfo>();
services.RemoveAll<TimeProvider>();
services.AddSingleton(_timeZone);
services.AddSingleton(_timeProvider);
});
}
}

private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;

public FixedTimeProvider(DateTimeOffset now) => _now = now;

public override DateTimeOffset GetUtcNow() => _now;
}
}
1 change: 1 addition & 0 deletions IPCamLapse/Pages/Sessions/Create.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
<div class="card shadow-sm mb-4">
<div class="card-header"><h2 class="h5 mb-0"><span class="form-section-number">03</span>Schedule</h2></div>
<div class="card-body">
<p class="small text-muted">Recurring windows use @Model.TimeZoneDisplay. Changing the host time zone requires an application restart.</p>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="Session.Configuration.Schedule.Frequency" class="form-label fw-semibold">Frequency</label>
Expand Down
28 changes: 22 additions & 6 deletions IPCamLapse/Pages/Sessions/Create.cshtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,28 @@ public sealed class CreateModel : PageModel
private readonly ICameraUrlPolicy _cameraUrlPolicy;
private readonly IStorageService _storage;
private readonly IApplicationSettingsService _applicationSettings;
private readonly ICaptureScheduleService _schedule;
private readonly TimeProvider _timeProvider;
private readonly TimeZoneInfo _timeZone;

public CreateModel(
ICaptureSessionService sessions,
ICameraProfileService profiles,
ICameraUrlPolicy cameraUrlPolicy,
IStorageService storage,
IApplicationSettingsService applicationSettings)
IApplicationSettingsService applicationSettings,
ICaptureScheduleService schedule,
TimeProvider timeProvider,
TimeZoneInfo timeZone)
{
_sessions = sessions;
_profiles = profiles;
_cameraUrlPolicy = cameraUrlPolicy;
_storage = storage;
_applicationSettings = applicationSettings;
_schedule = schedule;
_timeProvider = timeProvider;
_timeZone = timeZone;
}

[BindProperty]
Expand All @@ -37,6 +46,7 @@ public CreateModel(
public IReadOnlyList<CameraProfile> Profiles { get; private set; } = Array.Empty<CameraProfile>();
public StorageStatus Storage { get; private set; } = new(0, 1, 0, 0, false, null);
public long EstimatedFrameBytes { get; private set; } = 350 * 1024;
public string TimeZoneDisplay => FormatTimeZone(_timeZone, _timeProvider.GetUtcNow());

public async Task OnGetAsync()
{
Expand Down Expand Up @@ -75,12 +85,10 @@ public async Task<IActionResult> OnPostAsync()
Session.Configuration.AllowInvalidCertificate = false;
}
Session.Id = Guid.NewGuid().ToString("N")[..8];
Session.CreatedAt = DateTime.UtcNow;
Session.CreatedAt = _timeProvider.GetUtcNow().UtcDateTime;
Session.Status = SessionStatus.Ready;
Session.Configuration.Schedule.StartAtUtc = StartAtLocal.HasValue
? TimeZoneInfo.ConvertTimeToUtc(
DateTime.SpecifyKind(StartAtLocal.Value, DateTimeKind.Unspecified),
TimeZoneInfo.Local)
? _schedule.ConvertWallTimeToUtc(StartAtLocal.Value)
: null;
await _sessions.CreateSessionAsync(Session);
return RedirectToPage("/Sessions/Details", new { id = Session.Id });
Expand Down Expand Up @@ -116,7 +124,8 @@ private void ValidateSchedule()
var schedule = Session.Configuration.Schedule;
if (schedule.Frequency == ScheduleFrequency.Once && !StartAtLocal.HasValue)
ModelState.AddModelError(nameof(StartAtLocal), "Choose a start time.");
if (StartAtLocal.HasValue && StartAtLocal.Value <= DateTime.Now &&
if (StartAtLocal.HasValue &&
_schedule.ConvertWallTimeToUtc(StartAtLocal.Value) <= _timeProvider.GetUtcNow().UtcDateTime &&
schedule.Frequency == ScheduleFrequency.Once)
{
ModelState.AddModelError(nameof(StartAtLocal), "Start time must be in the future.");
Expand Down Expand Up @@ -144,4 +153,11 @@ private async Task LoadPageDataAsync()
Storage = await _storage.GetStatusAsync();
EstimatedFrameBytes = _applicationSettings.Current.EstimatedFrameBytes;
}

private static string FormatTimeZone(TimeZoneInfo timeZone, DateTimeOffset now)
{
var offset = timeZone.GetUtcOffset(now);
var sign = offset < TimeSpan.Zero ? "−" : "+";
return $"{timeZone.Id} (UTC{sign}{offset.Duration():hh\\:mm})";
}
}
2 changes: 2 additions & 0 deletions IPCamLapse/Pages/System.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
<span class="text-muted">@Model.Health.Checks.Count(check => check.Healthy) of @Model.Health.Checks.Count checks passed</span>
</div>

<p class="small text-muted mb-4">Active scheduling time zone: @Model.TimeZoneDisplay</p>

<div class="card shadow-sm mb-4">
<div class="card-body p-0">
<div class="list-group list-group-flush">
Expand Down
19 changes: 18 additions & 1 deletion IPCamLapse/Pages/System.cshtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,32 @@ public sealed class SystemModel : PageModel
{
private readonly ISystemHealthService _health;
private readonly IStorageService _storage;
private readonly TimeProvider _timeProvider;
private readonly TimeZoneInfo _timeZone;

public SystemModel(ISystemHealthService health, IStorageService storage)
public SystemModel(
ISystemHealthService health,
IStorageService storage,
TimeProvider timeProvider,
TimeZoneInfo timeZone)
{
_health = health;
_storage = storage;
_timeProvider = timeProvider;
_timeZone = timeZone;
}

public SystemHealthReport Health { get; private set; } = new(Array.Empty<HealthCheckItem>());
public StorageStatus Storage { get; private set; } = new(0, 1, 0, 0, false, null);
public string TimeZoneDisplay
{
get
{
var offset = _timeZone.GetUtcOffset(_timeProvider.GetUtcNow());
var sign = offset < TimeSpan.Zero ? "−" : "+";
return $"{_timeZone.Id} (UTC{sign}{offset.Duration():hh\\:mm})";
}
}

public async Task OnGetAsync()
{
Expand Down
1 change: 1 addition & 0 deletions IPCamLapse/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
IConfigureOptions<KeyManagementOptions>,
DataProtectionKeyRingOptionsSetup>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<TimeZoneInfo>(_ => TimeZoneInfo.Local);

builder.Services
.AddOptions<LocalAccessOptions>()
Expand Down
Loading