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)); }