diff --git a/Timetracker.Tests/AssemblyInfo.cs b/Timetracker.Tests/AssemblyInfo.cs
new file mode 100644
index 0000000..bf2645c
--- /dev/null
+++ b/Timetracker.Tests/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+// The clock seam (ValidationUtils.Clock) is process-global static state, so tests that
+// swap it must not run in parallel with tests that read the current date. The suite is
+// tiny, so serial execution is the simplest safe choice.
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
diff --git a/Timetracker.Tests/ConfigServiceTests.cs b/Timetracker.Tests/ConfigServiceTests.cs
new file mode 100644
index 0000000..798838d
--- /dev/null
+++ b/Timetracker.Tests/ConfigServiceTests.cs
@@ -0,0 +1,104 @@
+using Timetracker.Options;
+using Timetracker.Services;
+
+namespace Timetracker.Tests;
+
+public class ConfigServiceTests
+{
+ [Fact]
+ public void ConfigExists_FalseWhenEmpty_TrueAfterSave()
+ {
+ using var dir = new TempConfigDir();
+
+ Assert.False(ConfigService.ConfigExists());
+
+ ConfigService.SaveConfig(new ConfigOptions
+ {
+ TimetrackerUrl = "https://acme.timehub.7pace.com",
+ TimetrackerBearerToken = "token-123",
+ });
+
+ Assert.True(ConfigService.ConfigExists());
+ }
+
+ [Fact]
+ public void SaveThenLoad_RoundTripsValues()
+ {
+ using var dir = new TempConfigDir();
+
+ ConfigService.SaveConfig(
+ new ConfigOptions
+ {
+ TimetrackerUrl = "https://acme.timehub.7pace.com",
+ TimetrackerBearerToken = "secret-token",
+ Border = "square",
+ },
+ userId: "u-1",
+ displayName: "Jane",
+ email: "jane@acme.com",
+ accountName: "acme");
+
+ var config = ConfigService.LoadConfig();
+
+ Assert.Equal("https://acme.timehub.7pace.com", config.TimetrackerUrl);
+ Assert.Equal("secret-token", config.TimetrackerBearerToken); // decrypted round-trip
+ Assert.Equal("u-1", config.TimetrackerUserId);
+ Assert.Equal("Jane", config.DisplayName);
+ Assert.Equal("jane@acme.com", config.Email);
+ Assert.Equal("acme", config.AccountName);
+ Assert.Equal("square", config.TableBorder);
+ }
+
+ [Fact]
+ public void SaveConfig_IsNonDestructive_PreservesUnsuppliedValues()
+ {
+ using var dir = new TempConfigDir();
+
+ ConfigService.SaveConfig(new ConfigOptions
+ {
+ TimetrackerUrl = "https://acme.timehub.7pace.com",
+ TimetrackerBearerToken = "secret-token",
+ });
+
+ // Change only the border; url and token must survive.
+ ConfigService.SaveConfig(new ConfigOptions { Border = "markdown" });
+
+ var config = ConfigService.LoadConfig();
+
+ Assert.Equal("https://acme.timehub.7pace.com", config.TimetrackerUrl);
+ Assert.Equal("secret-token", config.TimetrackerBearerToken);
+ Assert.Equal("markdown", config.TableBorder);
+ }
+
+ [Fact]
+ public void GetTableBorder_ReturnsStoredBorderWithoutTouchingToken()
+ {
+ using var dir = new TempConfigDir();
+
+ ConfigService.SaveConfig(new ConfigOptions
+ {
+ TimetrackerUrl = "https://acme.timehub.7pace.com",
+ TimetrackerBearerToken = "secret-token",
+ Border = "minimal",
+ });
+
+ Assert.Equal("minimal", ConfigService.GetTableBorder());
+ }
+
+ [Fact]
+ public void DeleteConfig_RemovesTheFile()
+ {
+ using var dir = new TempConfigDir();
+
+ ConfigService.SaveConfig(new ConfigOptions
+ {
+ TimetrackerUrl = "https://acme.timehub.7pace.com",
+ TimetrackerBearerToken = "secret-token",
+ });
+ Assert.True(ConfigService.ConfigExists());
+
+ ConfigService.DeleteConfig();
+
+ Assert.False(ConfigService.ConfigExists());
+ }
+}
diff --git a/Timetracker.Tests/ConfigValidatorFirstTimeTests.cs b/Timetracker.Tests/ConfigValidatorFirstTimeTests.cs
new file mode 100644
index 0000000..81bd6ea
--- /dev/null
+++ b/Timetracker.Tests/ConfigValidatorFirstTimeTests.cs
@@ -0,0 +1,68 @@
+using Timetracker.Options;
+using Timetracker.Services;
+using Timetracker.Validators;
+
+namespace Timetracker.Tests;
+
+// Exercises the first-time-setup credential rules, which fire only when no config exists.
+// The TempConfigDir seam makes ConfigService.ConfigExists() deterministic.
+public class ConfigValidatorFirstTimeTests
+{
+ [Fact]
+ public void FirstTime_MissingToken_Fails()
+ {
+ using var dir = new TempConfigDir(); // empty -> ConfigExists() == false
+
+ var opts = new ConfigOptions { TimetrackerUrl = "https://acme.timehub.7pace.com" };
+
+ var result = new ConfigValidator().Validate(opts);
+
+ Assert.False(result.IsValid);
+ Assert.Contains(result.Errors, e => e.ErrorMessage.Contains("Bearer token is required"));
+ }
+
+ [Fact]
+ public void FirstTime_MissingUrl_Fails()
+ {
+ using var dir = new TempConfigDir();
+
+ var opts = new ConfigOptions { TimetrackerBearerToken = "token-123" };
+
+ var result = new ConfigValidator().Validate(opts);
+
+ Assert.False(result.IsValid);
+ Assert.Contains(result.Errors, e => e.ErrorMessage.Contains("Timetracker URL is required"));
+ }
+
+ [Fact]
+ public void FirstTime_UrlAndToken_IsValid()
+ {
+ using var dir = new TempConfigDir();
+
+ var opts = new ConfigOptions
+ {
+ TimetrackerUrl = "https://acme.timehub.7pace.com",
+ TimetrackerBearerToken = "token-123",
+ };
+
+ Assert.True(new ConfigValidator().Validate(opts).IsValid);
+ }
+
+ [Fact]
+ public void AfterConfigured_BorderOnly_IsValid()
+ {
+ using var dir = new TempConfigDir();
+
+ // Establish an existing config first.
+ ConfigService.SaveConfig(new ConfigOptions
+ {
+ TimetrackerUrl = "https://acme.timehub.7pace.com",
+ TimetrackerBearerToken = "token-123",
+ });
+
+ // Now a partial update with neither url nor token must be accepted.
+ var result = new ConfigValidator().Validate(new ConfigOptions { Border = "square" });
+
+ Assert.True(result.IsValid);
+ }
+}
diff --git a/Timetracker.Tests/FixedClock.cs b/Timetracker.Tests/FixedClock.cs
new file mode 100644
index 0000000..c61aa6f
--- /dev/null
+++ b/Timetracker.Tests/FixedClock.cs
@@ -0,0 +1,17 @@
+namespace Timetracker.Tests;
+
+///
+/// A pinned to a fixed local date, with the local time zone
+/// forced to UTC so GetLocalNow().Date is deterministic regardless of the host.
+///
+internal sealed class FixedClock : TimeProvider
+{
+ private readonly DateTimeOffset _now;
+
+ public FixedClock(int year, int month, int day)
+ => _now = new DateTimeOffset(year, month, day, 12, 0, 0, TimeSpan.Zero);
+
+ public override DateTimeOffset GetUtcNow() => _now;
+
+ public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
+}
diff --git a/Timetracker.Tests/ResolveDateTests.cs b/Timetracker.Tests/ResolveDateTests.cs
new file mode 100644
index 0000000..0e0c4ad
--- /dev/null
+++ b/Timetracker.Tests/ResolveDateTests.cs
@@ -0,0 +1,89 @@
+using Timetracker.Utils;
+
+namespace Timetracker.Tests;
+
+///
+/// Absolute-date tests for the clock-dependent resolvers, using a pinned .
+/// Restores the system clock after each test so no global state leaks.
+///
+public sealed class ResolveDateTests : IDisposable
+{
+ public void Dispose() => ValidationUtils.Clock = TimeProvider.System;
+
+ private static void PinTo(int year, int month, int day)
+ => ValidationUtils.Clock = new FixedClock(year, month, day);
+
+ [Fact]
+ public void ResolveDate_Today_UsesTheClock()
+ {
+ PinTo(2026, 3, 15);
+
+ Assert.Equal(new DateTime(2026, 3, 15), ValidationUtils.ResolveDate("today"));
+ Assert.Equal(new DateTime(2026, 3, 15), ValidationUtils.ResolveDate(""));
+ }
+
+ [Fact]
+ public void ResolveDate_Yesterday_UsesTheClock()
+ {
+ PinTo(2026, 3, 1);
+
+ Assert.Equal(new DateTime(2026, 2, 28), ValidationUtils.ResolveDate("yesterday"));
+ }
+
+ [Fact]
+ public void ResolveCurrentWeek_MidWeek_ReturnsThatWeeksMondayToSunday()
+ {
+ // 2026-03-18 is a Wednesday.
+ PinTo(2026, 3, 18);
+
+ var (from, to) = ValidationUtils.ResolveCurrentWeek();
+
+ Assert.Equal(new DateTime(2026, 3, 16), from); // Monday
+ Assert.Equal(new DateTime(2026, 3, 22), to); // Sunday
+ }
+
+ [Fact]
+ public void ResolveCurrentWeek_OnSunday_ReturnsTheEndingWeek()
+ {
+ // 2026-03-22 is a Sunday — the week should still be 03-16..03-22.
+ PinTo(2026, 3, 22);
+
+ var (from, to) = ValidationUtils.ResolveCurrentWeek();
+
+ Assert.Equal(new DateTime(2026, 3, 16), from);
+ Assert.Equal(new DateTime(2026, 3, 22), to);
+ }
+
+ [Fact]
+ public void ResolveLastWeek_ReturnsPreviousMondayToSunday()
+ {
+ PinTo(2026, 3, 18);
+
+ var (from, to) = ValidationUtils.ResolveLastWeek();
+
+ Assert.Equal(new DateTime(2026, 3, 9), from);
+ Assert.Equal(new DateTime(2026, 3, 15), to);
+ }
+
+ [Fact]
+ public void ResolveCurrentMonth_ReturnsFirstToLastDay()
+ {
+ PinTo(2026, 2, 10);
+
+ var (from, to) = ValidationUtils.ResolveCurrentMonth();
+
+ Assert.Equal(new DateTime(2026, 2, 1), from);
+ Assert.Equal(new DateTime(2026, 2, 28), to); // non-leap
+ }
+
+ [Fact]
+ public void ResolveLastMonth_AtStartOfYear_RollsBackToDecember()
+ {
+ PinTo(2026, 1, 5);
+
+ var (from, to) = ValidationUtils.ResolveLastMonth();
+
+ Assert.Equal(new DateTime(2025, 12, 1), from);
+ Assert.Equal(new DateTime(2025, 12, 31), to);
+ }
+}
diff --git a/Timetracker.Tests/TempConfigDir.cs b/Timetracker.Tests/TempConfigDir.cs
new file mode 100644
index 0000000..55901be
--- /dev/null
+++ b/Timetracker.Tests/TempConfigDir.cs
@@ -0,0 +1,27 @@
+namespace Timetracker.Tests;
+
+///
+/// Points ConfigService at a throwaway directory via the TIMETRACKER_CONFIG_DIR
+/// override for the lifetime of the instance, then removes it. Never touches the real store.
+///
+internal sealed class TempConfigDir : IDisposable
+{
+ private const string EnvVar = "TIMETRACKER_CONFIG_DIR";
+ private readonly string _previous;
+
+ public string Path { get; }
+
+ public TempConfigDir()
+ {
+ Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "tt-tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path);
+ _previous = Environment.GetEnvironmentVariable(EnvVar);
+ Environment.SetEnvironmentVariable(EnvVar, Path);
+ }
+
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable(EnvVar, _previous);
+ try { Directory.Delete(Path, recursive: true); } catch { /* best-effort cleanup */ }
+ }
+}
diff --git a/Timetracker/Services/ConfigService.cs b/Timetracker/Services/ConfigService.cs
index 640f4e7..ae21c8a 100644
--- a/Timetracker/Services/ConfigService.cs
+++ b/Timetracker/Services/ConfigService.cs
@@ -24,11 +24,19 @@ public static class ConfigService
private const string APPLICATION_NAME = "Timetracker.Console";
private const string JSON_FILE_NAME = "config.json";
+ // Overrides the config directory when set (used by tests to avoid touching the real
+ // user store). Not documented as a public feature; the runtime path is unchanged.
+ private const string CONFIG_DIR_ENV = "TIMETRACKER_CONFIG_DIR";
+
private static string GetConfigPath()
{
- var folderPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
- ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), APPLICATION_NAME)
- : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", APPLICATION_NAME);
+ var overrideDir = Environment.GetEnvironmentVariable(CONFIG_DIR_ENV);
+
+ var folderPath = !string.IsNullOrEmpty(overrideDir)
+ ? overrideDir
+ : RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), APPLICATION_NAME)
+ : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", APPLICATION_NAME);
return Path.Combine(folderPath, JSON_FILE_NAME);
}
diff --git a/Timetracker/Timetracker.csproj b/Timetracker/Timetracker.csproj
index d6a3fb3..e20c9fd 100644
--- a/Timetracker/Timetracker.csproj
+++ b/Timetracker/Timetracker.csproj
@@ -21,6 +21,10 @@
+
+
+
+
diff --git a/Timetracker/Utils/ValidationUtils.cs b/Timetracker/Utils/ValidationUtils.cs
index 3deb53d..e7970a7 100644
--- a/Timetracker/Utils/ValidationUtils.cs
+++ b/Timetracker/Utils/ValidationUtils.cs
@@ -5,6 +5,14 @@ namespace Timetracker.Utils;
public static class ValidationUtils
{
+ ///
+ /// Clock used by the date-resolving helpers. Defaults to the system clock; tests can
+ /// swap it for a fixed provider to assert absolute week/month ranges deterministically.
+ ///
+ internal static TimeProvider Clock { get; set; } = TimeProvider.System;
+
+ private static DateTime Today => Clock.GetLocalNow().Date;
+
public static bool ValidDate(string date) => DateTime.TryParse(date, out _);
public static bool ValidActivityDate(string date)
@@ -25,17 +33,17 @@ public static bool ValidUrl(string url) =>
public static DateTime ResolveDate(string input)
{
if (string.IsNullOrEmpty(input) || input.Equals("today", StringComparison.OrdinalIgnoreCase))
- return DateTime.Today;
+ return Today;
if (input.Equals("yesterday", StringComparison.OrdinalIgnoreCase))
- return DateTime.Today.AddDays(-1);
+ return Today.AddDays(-1);
return DateTime.Parse(input);
}
public static (DateTime From, DateTime To) ResolveCurrentWeek()
{
- var today = DateTime.Today;
+ var today = Today;
var diff = (7 + (today.DayOfWeek - DayOfWeek.Monday)) % 7;
var monday = today.AddDays(-diff);
return (monday, monday.AddDays(6));
@@ -50,14 +58,14 @@ public static (DateTime From, DateTime To) ResolveLastWeek()
public static (DateTime From, DateTime To) ResolveCurrentMonth()
{
- var today = DateTime.Today;
+ var today = Today;
var firstDay = new DateTime(today.Year, today.Month, 1);
return (firstDay, firstDay.AddMonths(1).AddDays(-1));
}
public static (DateTime From, DateTime To) ResolveLastMonth()
{
- var today = DateTime.Today;
+ var today = Today;
var firstDay = new DateTime(today.Year, today.Month, 1).AddMonths(-1);
return (firstDay, firstDay.AddMonths(1).AddDays(-1));
}