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 @@ +