Skip to content
Merged
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
4 changes: 4 additions & 0 deletions Timetracker.Tests/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -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)]
104 changes: 104 additions & 0 deletions Timetracker.Tests/ConfigServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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());
}
}
68 changes: 68 additions & 0 deletions Timetracker.Tests/ConfigValidatorFirstTimeTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
17 changes: 17 additions & 0 deletions Timetracker.Tests/FixedClock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Timetracker.Tests;

/// <summary>
/// A <see cref="TimeProvider"/> pinned to a fixed local date, with the local time zone
/// forced to UTC so <c>GetLocalNow().Date</c> is deterministic regardless of the host.
/// </summary>
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;
}
89 changes: 89 additions & 0 deletions Timetracker.Tests/ResolveDateTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using Timetracker.Utils;

namespace Timetracker.Tests;

/// <summary>
/// Absolute-date tests for the clock-dependent resolvers, using a pinned <see cref="FixedClock"/>.
/// Restores the system clock after each test so no global state leaks.
/// </summary>
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);
}
}
27 changes: 27 additions & 0 deletions Timetracker.Tests/TempConfigDir.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Timetracker.Tests;

/// <summary>
/// Points <c>ConfigService</c> at a throwaway directory via the TIMETRACKER_CONFIG_DIR
/// override for the lifetime of the instance, then removes it. Never touches the real store.
/// </summary>
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 */ }
}
}
14 changes: 11 additions & 3 deletions Timetracker/Services/ConfigService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions Timetracker/Timetracker.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
<None Include="../README.md" Pack="true" PackagePath="/" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Timetracker.Tests" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
Expand Down
Loading
Loading