diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3fd5803..371faa9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,5 +20,8 @@ jobs:
- name: Build solution
run: dotnet build Timetracker.slnx -c Release
+ - name: Test
+ run: dotnet test Timetracker.slnx -c Release --no-build
+
- name: Pack (validate packaging)
run: dotnet pack Timetracker -c Release -o ./nupkg
diff --git a/Timetracker.Tests/AddValidatorTests.cs b/Timetracker.Tests/AddValidatorTests.cs
new file mode 100644
index 0000000..5769988
--- /dev/null
+++ b/Timetracker.Tests/AddValidatorTests.cs
@@ -0,0 +1,89 @@
+using Timetracker.Options;
+using Timetracker.Validators;
+
+namespace Timetracker.Tests;
+
+public class AddValidatorTests
+{
+ private static readonly string[] Activities = ["DEVELOPMENT", "TESTING"];
+
+ private static AddOptions ValidOptions() => new()
+ {
+ ActivityDate = "today",
+ WorkItemId = 12345,
+ ActivityLength = 2m,
+ ActivityType = "Development",
+ ActivityStartHour = "09:00",
+ ActivityComment = null,
+ };
+
+ [Fact]
+ public void HappyPath_IsValid()
+ {
+ var result = new AddValidator(Activities).Validate(ValidOptions());
+
+ Assert.True(result.IsValid);
+ }
+
+ [Theory]
+ [InlineData("2026-06-15")]
+ [InlineData("tomorrow")]
+ [InlineData("")]
+ public void InvalidDate_Fails(string date)
+ {
+ var opts = ValidOptions();
+ opts.ActivityDate = date;
+
+ Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void NonPositiveWorkItem_Fails(int id)
+ {
+ var opts = ValidOptions();
+ opts.WorkItemId = id;
+
+ Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
+ }
+
+ [Fact]
+ public void NonPositiveLength_Fails()
+ {
+ var opts = ValidOptions();
+ opts.ActivityLength = 0m;
+
+ Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
+ }
+
+ [Fact]
+ public void UnknownActivityType_Fails()
+ {
+ var opts = ValidOptions();
+ opts.ActivityType = "Meeting";
+
+ Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
+ }
+
+ [Theory]
+ [InlineData("24:00")]
+ [InlineData("9:60")]
+ [InlineData("noon")]
+ public void InvalidStartHour_Fails(string hour)
+ {
+ var opts = ValidOptions();
+ opts.ActivityStartHour = hour;
+
+ Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
+ }
+
+ [Fact]
+ public void ShortComment_Fails()
+ {
+ var opts = ValidOptions();
+ opts.ActivityComment = "ab";
+
+ Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
+ }
+}
diff --git a/Timetracker.Tests/ConfigValidatorTests.cs b/Timetracker.Tests/ConfigValidatorTests.cs
new file mode 100644
index 0000000..95ad2c2
--- /dev/null
+++ b/Timetracker.Tests/ConfigValidatorTests.cs
@@ -0,0 +1,56 @@
+using Timetracker.Options;
+using Timetracker.Validators;
+
+namespace Timetracker.Tests;
+
+// Only the rules that do not depend on ConfigService.ConfigExists() are covered here.
+// The first-time-setup credential rules touch the real config store and are deferred to
+// Phase 2, once that existence check can be injected.
+public class ConfigValidatorTests
+{
+ [Fact]
+ public void NoOptions_Fails()
+ {
+ var result = new ConfigValidator().Validate(new ConfigOptions());
+
+ Assert.False(result.IsValid);
+ Assert.Contains(result.Errors, e => e.ErrorMessage.Contains("at least one option"));
+ }
+
+ [Theory]
+ [InlineData("minimal")]
+ [InlineData("square")]
+ [InlineData("markdown")]
+ [InlineData("MARKDOWN")]
+ public void ValidBorder_IsValid(string border)
+ {
+ // Show bypasses the first-time credential rules, isolating the border rule.
+ var opts = new ConfigOptions { Show = true, Border = border };
+
+ Assert.True(new ConfigValidator().Validate(opts).IsValid);
+ }
+
+ [Fact]
+ public void InvalidBorder_Fails()
+ {
+ var opts = new ConfigOptions { Show = true, Border = "fancy" };
+
+ Assert.False(new ConfigValidator().Validate(opts).IsValid);
+ }
+
+ [Fact]
+ public void NonHttpsUrl_Fails()
+ {
+ var opts = new ConfigOptions { Show = true, TimetrackerUrl = "http://acme.timehub.7pace.com" };
+
+ Assert.False(new ConfigValidator().Validate(opts).IsValid);
+ }
+
+ [Fact]
+ public void HttpsUrl_IsValid()
+ {
+ var opts = new ConfigOptions { Show = true, TimetrackerUrl = "https://acme.timehub.7pace.com" };
+
+ Assert.True(new ConfigValidator().Validate(opts).IsValid);
+ }
+}
diff --git a/Timetracker.Tests/FakePeriodOptions.cs b/Timetracker.Tests/FakePeriodOptions.cs
new file mode 100644
index 0000000..f27faf7
--- /dev/null
+++ b/Timetracker.Tests/FakePeriodOptions.cs
@@ -0,0 +1,17 @@
+using Timetracker.Options;
+
+namespace Timetracker.Tests;
+
+/// Mutable for exercising PeriodResolver.
+internal sealed class FakePeriodOptions : IPeriodOptions
+{
+ public string From { get; init; }
+ public string To { get; init; }
+ public string Period { get; init; }
+ public bool Today { get; init; }
+ public bool Yesterday { get; init; }
+ public bool Week { get; init; }
+ public bool LastWeek { get; init; }
+ public bool Month { get; init; }
+ public bool LastMonth { get; init; }
+}
diff --git a/Timetracker.Tests/PeriodResolverTests.cs b/Timetracker.Tests/PeriodResolverTests.cs
new file mode 100644
index 0000000..8389c7a
--- /dev/null
+++ b/Timetracker.Tests/PeriodResolverTests.cs
@@ -0,0 +1,179 @@
+using Timetracker.Utils;
+
+namespace Timetracker.Tests;
+
+public class PeriodResolverTests
+{
+ // --- Error paths -------------------------------------------------------
+
+ [Fact]
+ public void MutuallyExclusiveFlags_Fail()
+ {
+ var opts = new FakePeriodOptions { Today = true, Week = true };
+
+ var ok = PeriodResolver.TryResolve(opts, out _, out _, out var error);
+
+ Assert.False(ok);
+ Assert.Contains("mutually exclusive", error);
+ }
+
+ [Fact]
+ public void PeriodPlusExplicitPeriod_Fail()
+ {
+ var opts = new FakePeriodOptions { Month = true, Period = "2026/06" };
+
+ var ok = PeriodResolver.TryResolve(opts, out _, out _, out var error);
+
+ Assert.False(ok);
+ Assert.Contains("mutually exclusive", error);
+ }
+
+ [Fact]
+ public void ShortcutWithFromOrTo_Fail()
+ {
+ var opts = new FakePeriodOptions { Week = true, From = "2026/06/01" };
+
+ var ok = PeriodResolver.TryResolve(opts, out _, out _, out var error);
+
+ Assert.False(ok);
+ Assert.Contains("shortcuts cannot be used", error);
+ }
+
+ [Fact]
+ public void PeriodWithFromOrTo_Fail()
+ {
+ var opts = new FakePeriodOptions { Period = "2026/06", To = "2026/06/15" };
+
+ var ok = PeriodResolver.TryResolve(opts, out _, out _, out var error);
+
+ Assert.False(ok);
+ Assert.Contains("--period cannot be used", error);
+ }
+
+ [Theory]
+ [InlineData("2026-06")]
+ [InlineData("2026/13")]
+ [InlineData("June")]
+ [InlineData("26/06")]
+ public void InvalidPeriodFormat_Fail(string period)
+ {
+ var opts = new FakePeriodOptions { Period = period };
+
+ var ok = PeriodResolver.TryResolve(opts, out _, out _, out var error);
+
+ Assert.False(ok);
+ Assert.Contains("Invalid period format", error);
+ }
+
+ [Fact]
+ public void FromAfterTo_Fail()
+ {
+ var opts = new FakePeriodOptions { From = "2026/06/30", To = "2026/06/01" };
+
+ var ok = PeriodResolver.TryResolve(opts, out _, out _, out var error);
+
+ Assert.False(ok);
+ Assert.Contains("must be earlier than or equal", error);
+ }
+
+ // --- Success paths -----------------------------------------------------
+
+ [Fact]
+ public void NoOptions_DefaultsToToday()
+ {
+ var ok = PeriodResolver.TryResolve(new FakePeriodOptions(), out var from, out var to, out _);
+
+ Assert.True(ok);
+ Assert.Equal(DateTime.Today, from);
+ Assert.Equal(DateTime.Today, to);
+ }
+
+ [Fact]
+ public void Today_ResolvesToToday()
+ {
+ var ok = PeriodResolver.TryResolve(new FakePeriodOptions { Today = true }, out var from, out var to, out _);
+
+ Assert.True(ok);
+ Assert.Equal(DateTime.Today, from);
+ Assert.Equal(DateTime.Today, to);
+ }
+
+ [Fact]
+ public void Yesterday_ResolvesToYesterday()
+ {
+ var ok = PeriodResolver.TryResolve(new FakePeriodOptions { Yesterday = true }, out var from, out var to, out _);
+
+ Assert.True(ok);
+ Assert.Equal(DateTime.Today.AddDays(-1), from);
+ Assert.Equal(DateTime.Today.AddDays(-1), to);
+ }
+
+ [Fact]
+ public void ExplicitRange_IsPreserved()
+ {
+ var opts = new FakePeriodOptions { From = "2026/06/10", To = "2026/06/20" };
+
+ var ok = PeriodResolver.TryResolve(opts, out var from, out var to, out _);
+
+ Assert.True(ok);
+ Assert.Equal(new DateTime(2026, 6, 10), from);
+ Assert.Equal(new DateTime(2026, 6, 20), to);
+ }
+
+ [Fact]
+ public void Period_ResolvesToWholeMonth()
+ {
+ var opts = new FakePeriodOptions { Period = "2026/02" };
+
+ var ok = PeriodResolver.TryResolve(opts, out var from, out var to, out _);
+
+ Assert.True(ok);
+ Assert.Equal(new DateTime(2026, 2, 1), from);
+ Assert.Equal(new DateTime(2026, 2, 28), to); // 2026 is not a leap year
+ }
+
+ // --- Time-relative invariants (independent of the actual "today") ------
+
+ [Fact]
+ public void Week_IsMondayToSunday()
+ {
+ var ok = PeriodResolver.TryResolve(new FakePeriodOptions { Week = true }, out var from, out var to, out _);
+
+ Assert.True(ok);
+ Assert.Equal(DayOfWeek.Monday, from.DayOfWeek);
+ Assert.Equal(DayOfWeek.Sunday, to.DayOfWeek);
+ Assert.Equal(6, (to - from).Days);
+ }
+
+ [Fact]
+ public void LastWeek_IsExactlySevenDaysBeforeThisWeek()
+ {
+ PeriodResolver.TryResolve(new FakePeriodOptions { Week = true }, out var weekFrom, out var weekTo, out _);
+ PeriodResolver.TryResolve(new FakePeriodOptions { LastWeek = true }, out var lastFrom, out var lastTo, out _);
+
+ Assert.Equal(weekFrom.AddDays(-7), lastFrom);
+ Assert.Equal(weekTo.AddDays(-7), lastTo);
+ }
+
+ [Fact]
+ public void Month_CoversFirstToLastDayOfCurrentMonth()
+ {
+ var ok = PeriodResolver.TryResolve(new FakePeriodOptions { Month = true }, out var from, out var to, out _);
+
+ Assert.True(ok);
+ Assert.Equal(1, from.Day);
+ Assert.Equal(DateTime.Today.Month, from.Month);
+ Assert.Equal(from.AddMonths(1).AddDays(-1), to);
+ }
+
+ [Fact]
+ public void LastMonth_CoversFirstToLastDayOfPreviousMonth()
+ {
+ var ok = PeriodResolver.TryResolve(new FakePeriodOptions { LastMonth = true }, out var from, out var to, out _);
+
+ Assert.True(ok);
+ Assert.Equal(1, from.Day);
+ Assert.Equal(from.AddMonths(1).AddDays(-1), to);
+ Assert.Equal(DateTime.Today.Month, from.AddMonths(1).Month);
+ }
+}
diff --git a/Timetracker.Tests/Timetracker.Tests.csproj b/Timetracker.Tests/Timetracker.Tests.csproj
new file mode 100644
index 0000000..6374d67
--- /dev/null
+++ b/Timetracker.Tests/Timetracker.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ disable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Timetracker.Tests/UpdateValidatorTests.cs b/Timetracker.Tests/UpdateValidatorTests.cs
new file mode 100644
index 0000000..9732316
--- /dev/null
+++ b/Timetracker.Tests/UpdateValidatorTests.cs
@@ -0,0 +1,50 @@
+using Timetracker.Options;
+using Timetracker.Validators;
+
+namespace Timetracker.Tests;
+
+public class UpdateValidatorTests
+{
+ private static readonly string[] Activities = ["DEVELOPMENT", "TESTING"];
+
+ [Fact]
+ public void NoFields_Fails()
+ {
+ var result = new UpdateValidator(Activities).Validate(new UpdateOptions());
+
+ Assert.False(result.IsValid);
+ Assert.Contains(result.Errors, e => e.ErrorMessage.Contains("At least one field"));
+ }
+
+ [Fact]
+ public void SingleValidField_IsValid()
+ {
+ var opts = new UpdateOptions { ActivityLength = 3m };
+
+ Assert.True(new UpdateValidator(Activities).Validate(opts).IsValid);
+ }
+
+ [Fact]
+ public void ProvidedButInvalidDate_Fails()
+ {
+ var opts = new UpdateOptions { ActivityDate = "2026-06-15" };
+
+ Assert.False(new UpdateValidator(Activities).Validate(opts).IsValid);
+ }
+
+ [Fact]
+ public void ProvidedButUnknownType_Fails()
+ {
+ var opts = new UpdateOptions { ActivityType = "Meeting" };
+
+ Assert.False(new UpdateValidator(Activities).Validate(opts).IsValid);
+ }
+
+ [Fact]
+ public void ProvidedButNonPositiveWorkItem_Fails()
+ {
+ var opts = new UpdateOptions { WorkItemId = 0 };
+
+ Assert.False(new UpdateValidator(Activities).Validate(opts).IsValid);
+ }
+}
diff --git a/Timetracker.Tests/ValidationUtilsTests.cs b/Timetracker.Tests/ValidationUtilsTests.cs
new file mode 100644
index 0000000..0679311
--- /dev/null
+++ b/Timetracker.Tests/ValidationUtilsTests.cs
@@ -0,0 +1,59 @@
+using Timetracker.Utils;
+
+namespace Timetracker.Tests;
+
+public class ValidationUtilsTests
+{
+ [Theory]
+ [InlineData("today", true)]
+ [InlineData("TODAY", true)]
+ [InlineData("yesterday", true)]
+ [InlineData("2026/06/15", true)]
+ [InlineData("2026/6/15", false)] // needs zero-padded MM/DD
+ [InlineData("2026-06-15", false)] // wrong separator
+ [InlineData("", false)]
+ [InlineData(null, false)]
+ [InlineData("tomorrow", false)]
+ public void ValidActivityDate(string input, bool expected)
+ => Assert.Equal(expected, ValidationUtils.ValidActivityDate(input));
+
+ [Theory]
+ [InlineData("https://acme.timehub.7pace.com", true)]
+ [InlineData("http://acme.timehub.7pace.com", false)] // HTTPS enforced
+ [InlineData("ftp://acme.com", false)]
+ [InlineData("not-a-url", false)]
+ [InlineData("", false)]
+ public void ValidUrl(string input, bool expected)
+ => Assert.Equal(expected, ValidationUtils.ValidUrl(input));
+
+ [Theory]
+ [InlineData("2026/06", true, 2026, 6, 30)]
+ [InlineData("2026/02", true, 2026, 2, 28)]
+ [InlineData("2024/02", true, 2024, 2, 29)] // leap year
+ [InlineData("2026/13", false, 0, 0, 0)]
+ [InlineData("2026-06", false, 0, 0, 0)]
+ [InlineData("June", false, 0, 0, 0)]
+ public void TryResolveMonth(string input, bool expectedOk, int year, int month, int lastDay)
+ {
+ var ok = ValidationUtils.TryResolveMonth(input, out var first, out var last);
+
+ Assert.Equal(expectedOk, ok);
+ if (expectedOk)
+ {
+ Assert.Equal(new DateTime(year, month, 1), first);
+ Assert.Equal(new DateTime(year, month, lastDay), last);
+ }
+ }
+
+ [Theory]
+ [InlineData("development", true)] // input is upper-cased before matching
+ [InlineData("Development", true)]
+ [InlineData("DEVELOPMENT", true)]
+ [InlineData("meeting", false)]
+ public void ValidType_IsCaseInsensitiveAndUpperCases(string input, bool expected)
+ {
+ var activities = new[] { "DEVELOPMENT", "TESTING" };
+
+ Assert.Equal(expected, ValidationUtils.ValidType(activities, input));
+ }
+}
diff --git a/Timetracker.slnx b/Timetracker.slnx
index 676f973..64e16e4 100644
--- a/Timetracker.slnx
+++ b/Timetracker.slnx
@@ -1,3 +1,4 @@
+