diff --git a/README.md b/README.md index ad83c1d..f20b985 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,9 @@ Leave create options: - `--cc` comma-separated list of emails to notify - `--half-day` for partial day requests (start and end date must be the same) - `--start-time` / `--end-time` override defaults (09:00/18:00) +- `--timezone` overrides the request timezone with an IANA or Windows timezone ID + +Leave create uses `--timezone` first when supplied, then the TimePro user profile timezone when it is set. If neither is set, the CLI/MCP host uses the machine timezone as the browser-equivalent fallback; agents can control that by choosing the environment used to launch `tp`. ### Week View @@ -547,7 +550,7 @@ Current default tool groups include: |-------|----------| | Timesheets | Get, create, update, delete, suggested timesheets, accept suggestions, list iterations, `check_week` (leave-aware weekly coverage) | | Lookup | Search clients, list projects, get client rate, CRM bookings, location and repo mapping | -| Leave | List EasyLeave entries (optionally filtered by `empId`), `get_leave_balance` (days since last leave + 12-month hours) | +| Leave | List EasyLeave entries (optionally filtered by `empId`), create EasyLeave requests using timezone override/profile/machine fallback, `get_leave_balance` (days since last leave + 12-month hours) | Optional accounting MCP tools are enabled with: diff --git a/src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs b/src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs index b729744..0aa8e25 100644 --- a/src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs +++ b/src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs @@ -51,6 +51,10 @@ public class Settings : CommandSettings [Description("End time override (HH:mm, default: 18:00)")] public string? EndTime { get; set; } + [CommandOption("--timezone ")] + [Description("Timezone override (IANA or Windows ID); takes priority over the TimePro user profile timezone")] + public string? TimeZoneId { get; set; } + [CommandOption("--yes")] [Description("Skip confirmation")] public bool Yes { get; set; } @@ -89,31 +93,25 @@ protected override async Task ExecuteAsync(CommandContext context, Settings try { - // Parse dates with timezone offset - if (!DateTimeOffset.TryParse(settings.Start, out var startDate)) + var settingsDetails = string.IsNullOrWhiteSpace(settings.TimeZoneId) + ? await _api.GetEmployeeSettingsAsync(cancellationToken) + : null; + if (!LeaveRequestParser.TryResolveRequestTimeZone(settings.TimeZoneId, settingsDetails, out var requestTimeZone, out var timeZoneError)) { - // Support plain yyyy-MM-dd by adding local timezone offset - if (!DateOnly.TryParse(settings.Start, out var startDateOnly)) - { - OutputHelper.WriteError($"Invalid start date: '{settings.Start}'. Use yyyy-MM-dd format."); - return 1; - } - startDate = new DateTimeOffset(startDateOnly.ToDateTime(TimeOnly.MinValue), TimeZoneInfo.Local.GetUtcOffset(DateTime.Now)); + WriteValidationError(settings.Json, timeZoneError ?? "Invalid leave request timezone"); + return 1; } - if (!DateTimeOffset.TryParse(settings.End, out var endDate)) + if (!LeaveRequestParser.TryParseDateRange( + settings.Start, + settings.End, + requestTimeZone, + out var startDate, + out var endDate, + out var dateError)) { - if (!DateOnly.TryParse(settings.End, out var endDateOnly)) - { - OutputHelper.WriteError($"Invalid end date: '{settings.End}'. Use yyyy-MM-dd format."); - return 1; - } - endDate = new DateTimeOffset(endDateOnly.ToDateTime(new TimeOnly(23, 59, 0)), TimeZoneInfo.Local.GetUtcOffset(DateTime.Now)); - } - else if (endDate.TimeOfDay == TimeSpan.Zero) - { - // If end date was parsed but has no time component, set to 23:59 - endDate = new DateTimeOffset(endDate.Date.AddHours(23).AddMinutes(59), endDate.Offset); + WriteValidationError(settings.Json, dateError ?? "Invalid leave date range"); + return 1; } if (IsWeekend(startDate) || IsWeekend(endDate)) @@ -135,16 +133,9 @@ protected override async Task ExecuteAsync(CommandContext context, Settings } var allDay = !settings.HalfDay; - var userStartTime = settings.StartTime ?? "09:00:00"; - var userEndTime = settings.EndTime ?? "18:00:00"; - - // Normalize time format to HH:mm:ss - if (userStartTime.Length == 5) userStartTime += ":00"; - if (userEndTime.Length == 5) userEndTime += ":00"; - - var optionalEmps = string.IsNullOrEmpty(settings.OptionalEmp) - ? [] - : settings.OptionalEmp.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + var userStartTime = LeaveRequestParser.NormalizeTime(settings.StartTime, LeaveRequestParser.DefaultStartTime); + var userEndTime = LeaveRequestParser.NormalizeTime(settings.EndTime, LeaveRequestParser.DefaultEndTime); + var optionalEmps = LeaveRequestParser.ParseOptionalEmployees(settings.OptionalEmp); if (!settings.Yes && !settings.Json) { diff --git a/src/SSW.TimePro.Cli/Features/Leave/LeaveRequestParser.cs b/src/SSW.TimePro.Cli/Features/Leave/LeaveRequestParser.cs new file mode 100644 index 0000000..5be697f --- /dev/null +++ b/src/SSW.TimePro.Cli/Features/Leave/LeaveRequestParser.cs @@ -0,0 +1,156 @@ +using System.Globalization; +using SSW.TimePro.Cli.Shared.Models; + +namespace SSW.TimePro.Cli.Features.Leave; + +internal static class LeaveRequestParser +{ + public const string DefaultStartTime = "09:00:00"; + public const string DefaultEndTime = "18:00:00"; + + public static bool TryParseDateRange( + string start, + string end, + TimeZoneInfo timeZone, + out DateTimeOffset startDate, + out DateTimeOffset endDate, + out string? error) + { + startDate = default; + endDate = default; + error = null; + + if (!TryParseStartDate(start, timeZone, out startDate)) + { + error = $"Invalid start date: '{start}'. Use yyyy-MM-dd format."; + return false; + } + + if (!TryParseEndDate(end, timeZone, out endDate)) + { + error = $"Invalid end date: '{end}'. Use yyyy-MM-dd format."; + return false; + } + + return true; + } + + public static string NormalizeTime(string? time, string defaultValue) + { + var normalized = string.IsNullOrWhiteSpace(time) ? defaultValue : time.Trim(); + return normalized.Length == 5 ? normalized + ":00" : normalized; + } + + public static List ParseOptionalEmployees(string? optionalEmp) => + string.IsNullOrWhiteSpace(optionalEmp) + ? [] + : optionalEmp.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + + public static bool TryResolveRequestTimeZone( + string? overrideTimeZoneId, + EmployeeSettings? settings, + out TimeZoneInfo timeZone, + out string? error) + { + if (!string.IsNullOrWhiteSpace(overrideTimeZoneId)) + { + if (TimeZoneInfo.TryFindSystemTimeZoneById(overrideTimeZoneId.Trim(), out timeZone!)) + { + error = null; + return true; + } + + error = $"Timezone override has an unknown timezone: '{overrideTimeZoneId}'. Use a valid IANA or Windows timezone ID."; + timeZone = TimeZoneInfo.Local; + return false; + } + + if (settings is not null && !string.IsNullOrWhiteSpace(settings.TimezoneId)) + { + if (TimeZoneInfo.TryFindSystemTimeZoneById(settings.TimezoneId.Trim(), out timeZone!)) + { + error = null; + return true; + } + + error = $"TimePro user profile has an unknown timezone: '{settings.TimezoneId}'. Update the profile timezone or run on a machine with that timezone available."; + timeZone = TimeZoneInfo.Local; + return false; + } + + timeZone = TimeZoneInfo.Local; + error = null; + return true; + } + + private static bool TryParseStartDate(string value, TimeZoneInfo timeZone, out DateTimeOffset result) + { + if (DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly)) + { + result = ToDateTimeOffset(dateOnly, TimeOnly.MinValue, timeZone); + return true; + } + + return TryParseDateTime(value, timeZone, useEndOfDayForDateOnly: false, out result); + } + + private static bool TryParseEndDate(string value, TimeZoneInfo timeZone, out DateTimeOffset result) + { + if (DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly)) + { + result = ToDateTimeOffset(dateOnly, new TimeOnly(23, 59, 0), timeZone); + return true; + } + + return TryParseDateTime(value, timeZone, useEndOfDayForDateOnly: true, out result); + } + + private static DateTimeOffset ToDateTimeOffset(DateOnly date, TimeOnly time, TimeZoneInfo timeZone) + { + var dateTime = date.ToDateTime(time); + return new DateTimeOffset(dateTime, timeZone.GetUtcOffset(dateTime)); + } + + private static bool TryParseDateTime( + string value, + TimeZoneInfo timeZone, + bool useEndOfDayForDateOnly, + out DateTimeOffset result) + { + var trimmed = value.Trim(); + + if (HasExplicitOffset(trimmed)) + return DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out result); + + if (!DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateTime)) + { + result = default; + return false; + } + + if (useEndOfDayForDateOnly && dateTime.TimeOfDay == TimeSpan.Zero) + dateTime = dateTime.Date.AddHours(23).AddMinutes(59); + + dateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Unspecified); + result = new DateTimeOffset(dateTime, timeZone.GetUtcOffset(dateTime)); + return true; + } + + private static bool HasExplicitOffset(string value) + { + if (value.EndsWith('Z') || value.EndsWith('z')) + return true; + + var timeSeparator = Math.Max(value.LastIndexOf('T'), value.LastIndexOf(' ')); + if (timeSeparator < 0) + return false; + + for (var i = value.Length - 1; i > timeSeparator; i--) + { + if (value[i] is '+' or '-') + return true; + } + + return false; + } +} diff --git a/src/SSW.TimePro.Cli/Features/Mcp/Tools/LeaveMcpTools.cs b/src/SSW.TimePro.Cli/Features/Mcp/Tools/LeaveMcpTools.cs index 3600e59..bce5b31 100644 --- a/src/SSW.TimePro.Cli/Features/Mcp/Tools/LeaveMcpTools.cs +++ b/src/SSW.TimePro.Cli/Features/Mcp/Tools/LeaveMcpTools.cs @@ -1,8 +1,10 @@ using System.ComponentModel; using System.Text.Json; using ModelContextProtocol.Server; +using SSW.TimePro.Cli.Features.Leave; using SSW.TimePro.Cli.Infrastructure.ApiClient; using SSW.TimePro.Cli.Infrastructure.Config; +using SSW.TimePro.Cli.Shared.Models; namespace SSW.TimePro.Cli.Features.Mcp.Tools; @@ -73,9 +75,80 @@ public async Task GetLeaveBalance( }, JsonOpts); } + [McpServerTool] + [Description("Create an EasyLeave request for the current user. An explicit timezone override takes priority; otherwise uses the TimePro profile timezone first, then the MCP host machine timezone as the browser-equivalent fallback.")] + public async Task CreateLeave( + [Description("Start date (yyyy-MM-dd)")] string start, + [Description("End date (yyyy-MM-dd)")] string end, + [Description("Leave type ID or active leave type name")] string type, + [Description("Leave note/reason")] string note, + [Description("Approver's email address")] string? approvedBy = null, + [Description("Comma-separated list of emails to notify")] string? cc = null, + [Description("Request partial-day leave; start and end must be the same day")] bool halfDay = false, + [Description("Start time override (HH:mm, default 09:00)")] string? startTime = null, + [Description("End time override (HH:mm, default 18:00)")] string? endTime = null, + [Description("Timezone override (IANA or Windows ID); takes priority over the TimePro user profile timezone")] string? timeZoneId = null, + CancellationToken ct = default) + { + var tenant = _config.LoadActiveTenantConfig(); + if (tenant?.EmployeeId is null) + return """{"error": "Not logged in. Run 'tp login --tenant ' first."}"""; + + if (string.IsNullOrWhiteSpace(note)) + return JsonSerializer.Serialize(new { error = "note is required: a reason/description is mandatory for leave" }, JsonOpts); + + var settings = string.IsNullOrWhiteSpace(timeZoneId) + ? await _api.GetEmployeeSettingsAsync(ct) + : null; + if (!LeaveRequestParser.TryResolveRequestTimeZone(timeZoneId, settings, out var requestTimeZone, out var timeZoneError)) + return JsonSerializer.Serialize(new { error = timeZoneError ?? "Invalid leave request timezone" }, JsonOpts); + + if (!LeaveRequestParser.TryParseDateRange(start, end, requestTimeZone, out var startDate, out var endDate, out var dateError)) + return JsonSerializer.Serialize(new { error = dateError ?? "Invalid leave date range" }, JsonOpts); + + if (IsWeekend(startDate) || IsWeekend(endDate)) + return JsonSerializer.Serialize(new { error = "Leave start and end dates must be weekdays" }, JsonOpts); + + var leaveTypeId = await ResolveLeaveTypeAsync(type, ct); + if (leaveTypeId is null) + return JsonSerializer.Serialize(new { error = $"Unknown leave type: '{type}'." }, JsonOpts); + + var request = new CreateLeaveRequest + { + RequestedEmpId = tenant.EmployeeId, + StartDate = startDate.ToString("o"), + EndDate = endDate.ToString("o"), + LeaveTypeId = leaveTypeId.Value, + Note = note.Trim(), + UserStartTime = LeaveRequestParser.NormalizeTime(startTime, LeaveRequestParser.DefaultStartTime), + UserEndTime = LeaveRequestParser.NormalizeTime(endTime, LeaveRequestParser.DefaultEndTime), + AllDay = !halfDay, + OptionalEmp = LeaveRequestParser.ParseOptionalEmployees(cc), + ApprovedBy = approvedBy, + TimeLessOverride = null + }; + + await _api.CreateLeaveAsync(request, ct); + return JsonSerializer.Serialize(new { success = true }, JsonOpts); + } + private static string? ResolveEmpId(string? empId, string? employeeId) { var requestedEmpId = !string.IsNullOrWhiteSpace(empId) ? empId : employeeId; return string.IsNullOrWhiteSpace(requestedEmpId) ? null : requestedEmpId.Trim(); } + + private async Task ResolveLeaveTypeAsync(string typeInput, CancellationToken ct) + { + if (int.TryParse(typeInput, out var id)) + return id; + + var types = await _api.GetLeaveTypesAsync(ct); + var match = types.FirstOrDefault(t => + t.Name.Equals(typeInput, StringComparison.OrdinalIgnoreCase)); + return match?.Id; + } + + private static bool IsWeekend(DateTimeOffset date) => + date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; } diff --git a/src/SSW.TimePro.Cli/Features/Skills/Templates/timepro-timesheets.md b/src/SSW.TimePro.Cli/Features/Skills/Templates/timepro-timesheets.md index c53117c..c739b19 100644 --- a/src/SSW.TimePro.Cli/Features/Skills/Templates/timepro-timesheets.md +++ b/src/SSW.TimePro.Cli/Features/Skills/Templates/timepro-timesheets.md @@ -63,6 +63,7 @@ tp leave list --filter UPCOMING --json tp leave create --start 2026-03-30 --end 2026-03-30 --type 1 \ --note "Reason" --approved-by "approver@northwind.example" \ --cc "notify1@northwind.example,notify2@northwind.example" --yes +# Leave create uses --timezone first, then the TimePro profile timezone, then the machine timezone. tp leave cancel --reason "Plans changed" --yes ``` diff --git a/tests/SSW.TimePro.Cli.Tests/Features/Leave/CreateCommandTests.cs b/tests/SSW.TimePro.Cli.Tests/Features/Leave/CreateCommandTests.cs index f668110..6479cd0 100644 --- a/tests/SSW.TimePro.Cli.Tests/Features/Leave/CreateCommandTests.cs +++ b/tests/SSW.TimePro.Cli.Tests/Features/Leave/CreateCommandTests.cs @@ -4,6 +4,7 @@ using SSW.TimePro.Cli.Infrastructure.ApiClient; using SSW.TimePro.Cli.Infrastructure.Config; using SSW.TimePro.Cli.Infrastructure.DependencyInjection; +using SSW.TimePro.Cli.Shared.Models; using Spectre.Console.Cli; using Xunit; @@ -56,6 +57,8 @@ public async Task Create_WhenNoteWhitespace_ReturnsErrorWithoutCallingApi() public async Task Create_WhenStartDateIsWeekend_ReturnsErrorWithoutCallingApi() { var api = Substitute.For(); + api.GetEmployeeSettingsAsync(Arg.Any()) + .Returns(new EmployeeSettings { TimezoneId = "UTC" }); var app = CreateApp(api); var exitCode = await app.RunAsync([ @@ -72,6 +75,162 @@ public async Task Create_WhenStartDateIsWeekend_ReturnsErrorWithoutCallingApi() api.ShouldNotHaveReceived(nameof(ITimeProApiClient.GetLeaveTypesAsync)); } + [Fact] + public async Task Create_WhenProfileTimezoneAvailable_SendsDateOffsetsFromProfileTimezone() + { + var api = Substitute.For(); + CreateLeaveRequest? request = null; + api.CreateLeaveAsync(Arg.Do(r => request = r), Arg.Any()) + .Returns(Task.CompletedTask); + var (timeZoneId, timeZone) = FindTimeZone("Australia/Brisbane", "E. Australia Standard Time", "UTC"); + api.GetEmployeeSettingsAsync(Arg.Any()) + .Returns(new EmployeeSettings { TimezoneId = timeZoneId }); + var app = CreateApp(api); + var expectedOffset = FormatOffset(timeZone.GetUtcOffset(new DateTime(2026, 3, 30, 0, 0, 0))); + + var exitCode = await app.RunAsync([ + "create", + "--start", "2026-03-30", + "--end", "2026-03-30", + "--type", "1", + "--note", "Annual leave", + "--json" + ], TestContext.Current.CancellationToken); + + exitCode.Should().Be(0); + request.Should().NotBeNull(); + request!.StartDate.Should().Be($"2026-03-30T00:00:00.0000000{expectedOffset}"); + request.EndDate.Should().Be($"2026-03-30T23:59:00.0000000{expectedOffset}"); + request.Note.Should().Be("Annual leave"); + } + + [Fact] + public async Task Create_WhenProfileTimezoneMissing_UsesMachineTimezone() + { + var api = Substitute.For(); + CreateLeaveRequest? request = null; + api.GetEmployeeSettingsAsync(Arg.Any()) + .Returns(new EmployeeSettings { TimezoneId = null }); + api.CreateLeaveAsync(Arg.Do(r => request = r), Arg.Any()) + .Returns(Task.CompletedTask); + var app = CreateApp(api); + var expectedOffset = FormatOffset(TimeZoneInfo.Local.GetUtcOffset(new DateTime(2026, 3, 30, 0, 0, 0))); + + var exitCode = await app.RunAsync([ + "create", + "--start", "2026-03-30", + "--end", "2026-03-30", + "--type", "1", + "--note", "Annual leave", + "--json" + ], TestContext.Current.CancellationToken); + + exitCode.Should().Be(0); + request.Should().NotBeNull(); + request!.StartDate.Should().Be($"2026-03-30T00:00:00.0000000{expectedOffset}"); + request.EndDate.Should().Be($"2026-03-30T23:59:00.0000000{expectedOffset}"); + } + + [Fact] + public async Task Create_WhenDateTimeHasNoOffset_UsesProfileTimezone() + { + var api = Substitute.For(); + CreateLeaveRequest? request = null; + api.CreateLeaveAsync(Arg.Do(r => request = r), Arg.Any()) + .Returns(Task.CompletedTask); + var (timeZoneId, timeZone) = FindTimeZone("Pacific/Auckland", "New Zealand Standard Time", "UTC"); + api.GetEmployeeSettingsAsync(Arg.Any()) + .Returns(new EmployeeSettings { TimezoneId = timeZoneId }); + var app = CreateApp(api); + var expectedOffset = FormatOffset(timeZone.GetUtcOffset(new DateTime(2026, 3, 30, 9, 30, 0))); + + var exitCode = await app.RunAsync([ + "create", + "--start", "2026-03-30T09:30:00", + "--end", "2026-03-30T17:30:00", + "--type", "1", + "--note", "Annual leave", + "--json" + ], TestContext.Current.CancellationToken); + + exitCode.Should().Be(0); + request.Should().NotBeNull(); + request!.StartDate.Should().Be($"2026-03-30T09:30:00.0000000{expectedOffset}"); + request.EndDate.Should().Be($"2026-03-30T17:30:00.0000000{expectedOffset}"); + } + + [Fact] + public async Task Create_WhenTimezoneOverrideProvided_UsesOverrideBeforeProfileTimezone() + { + var api = Substitute.For(); + CreateLeaveRequest? request = null; + api.CreateLeaveAsync(Arg.Do(r => request = r), Arg.Any()) + .Returns(Task.CompletedTask); + var (timeZoneId, timeZone) = FindTimeZone("Pacific/Auckland", "New Zealand Standard Time", "UTC"); + var app = CreateApp(api); + var expectedOffset = FormatOffset(timeZone.GetUtcOffset(new DateTime(2026, 3, 30, 0, 0, 0))); + + var exitCode = await app.RunAsync([ + "create", + "--start", "2026-03-30", + "--end", "2026-03-30", + "--type", "1", + "--note", "Annual leave", + "--timezone", timeZoneId, + "--json" + ], TestContext.Current.CancellationToken); + + exitCode.Should().Be(0); + request.Should().NotBeNull(); + request!.StartDate.Should().Be($"2026-03-30T00:00:00.0000000{expectedOffset}"); + request.EndDate.Should().Be($"2026-03-30T23:59:00.0000000{expectedOffset}"); + api.ShouldNotHaveReceived(nameof(ITimeProApiClient.GetEmployeeSettingsAsync)); + } + + [Fact] + public async Task Create_WhenTimezoneOverrideUnknown_ReturnsErrorWithoutCallingApi() + { + var api = Substitute.For(); + var app = CreateApp(api); + + var exitCode = await app.RunAsync([ + "create", + "--start", "2026-03-30", + "--end", "2026-03-30", + "--type", "1", + "--note", "Annual leave", + "--timezone", "Mars/Olympus_Mons", + "--json" + ], TestContext.Current.CancellationToken); + + exitCode.Should().Be(1); + api.ShouldNotHaveReceived(nameof(ITimeProApiClient.GetEmployeeSettingsAsync)); + api.ShouldNotHaveReceived(nameof(ITimeProApiClient.CreateLeaveAsync)); + api.ShouldNotHaveReceived(nameof(ITimeProApiClient.GetLeaveTypesAsync)); + } + + [Fact] + public async Task Create_WhenProfileTimezoneUnknown_ReturnsErrorWithoutCallingApi() + { + var api = Substitute.For(); + api.GetEmployeeSettingsAsync(Arg.Any()) + .Returns(new EmployeeSettings { TimezoneId = "Mars/Olympus_Mons" }); + var app = CreateApp(api); + + var exitCode = await app.RunAsync([ + "create", + "--start", "2026-03-30", + "--end", "2026-03-30", + "--type", "1", + "--note", "Annual leave", + "--json" + ], TestContext.Current.CancellationToken); + + exitCode.Should().Be(1); + api.ShouldNotHaveReceived(nameof(ITimeProApiClient.CreateLeaveAsync)); + api.ShouldNotHaveReceived(nameof(ITimeProApiClient.GetLeaveTypesAsync)); + } + private static CommandApp CreateApp(ITimeProApiClient api) { var tenantProvider = Substitute.For(); @@ -94,6 +253,24 @@ private static CommandApp CreateApp(ITimeProApiClient api) }); return app; } + + private static (string id, TimeZoneInfo timeZone) FindTimeZone(params string[] ids) + { + foreach (var id in ids) + { + if (TimeZoneInfo.TryFindSystemTimeZoneById(id, out var timeZone)) + return (id, timeZone); + } + + throw new InvalidOperationException("No test timezone was available."); + } + + private static string FormatOffset(TimeSpan offset) + { + var sign = offset < TimeSpan.Zero ? "-" : "+"; + offset = offset.Duration(); + return $"{sign}{offset.Hours:00}:{offset.Minutes:00}"; + } } internal static class SubstituteApiAssertions diff --git a/tests/SSW.TimePro.Cli.Tests/Features/Mcp/LeaveMcpToolsTests.cs b/tests/SSW.TimePro.Cli.Tests/Features/Mcp/LeaveMcpToolsTests.cs new file mode 100644 index 0000000..8cbdbe6 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Tests/Features/Mcp/LeaveMcpToolsTests.cs @@ -0,0 +1,174 @@ +using System.Text.Json; +using FluentAssertions; +using NSubstitute; +using SSW.TimePro.Cli.Features.Mcp.Tools; +using SSW.TimePro.Cli.Infrastructure.ApiClient; +using SSW.TimePro.Cli.Infrastructure.Config; +using SSW.TimePro.Cli.Shared.Models; +using Xunit; + +namespace SSW.TimePro.Cli.Tests.Features.Mcp; + +public class LeaveMcpToolsTests +{ + [Fact] + public async Task CreateLeave_WhenProfileTimezoneAvailable_SendsDateOffsetsFromProfileTimezone() + { + var api = Substitute.For(); + var config = Substitute.For(); + config.LoadActiveTenantConfig().Returns(new TenantConfig + { + TenantId = "test", + ApiUrl = "https://timepro.example", + ApiKey = "test-api-key", + EmployeeId = "TST" + }); + + CreateLeaveRequest? request = null; + api.CreateLeaveAsync(Arg.Do(r => request = r), Arg.Any()) + .Returns(Task.CompletedTask); + + var (timeZoneId, timeZone) = FindTimeZone("Australia/Brisbane", "E. Australia Standard Time", "UTC"); + api.GetEmployeeSettingsAsync(Arg.Any()) + .Returns(new EmployeeSettings { TimezoneId = timeZoneId }); + var expectedOffset = FormatOffset(timeZone.GetUtcOffset(new DateTime(2026, 3, 30, 0, 0, 0))); + var tools = new LeaveMcpTools(api, config); + + var json = await tools.CreateLeave( + start: "2026-03-30", + end: "2026-03-30", + type: "1", + note: "Annual leave", + ct: TestContext.Current.CancellationToken); + + using var doc = JsonDocument.Parse(json); + doc.RootElement.GetProperty("success").GetBoolean().Should().BeTrue(); + request.Should().NotBeNull(); + request!.RequestedEmpId.Should().Be("TST"); + request.StartDate.Should().Be($"2026-03-30T00:00:00.0000000{expectedOffset}"); + request.EndDate.Should().Be($"2026-03-30T23:59:00.0000000{expectedOffset}"); + request.Note.Should().Be("Annual leave"); + } + + [Fact] + public async Task CreateLeave_WhenProfileTimezoneMissing_UsesMachineTimezone() + { + var api = Substitute.For(); + var config = Substitute.For(); + config.LoadActiveTenantConfig().Returns(new TenantConfig + { + TenantId = "test", + ApiUrl = "https://timepro.example", + ApiKey = "test-api-key", + EmployeeId = "TST" + }); + + api.GetEmployeeSettingsAsync(Arg.Any()) + .Returns(new EmployeeSettings { TimezoneId = null }); + CreateLeaveRequest? request = null; + api.CreateLeaveAsync(Arg.Do(r => request = r), Arg.Any()) + .Returns(Task.CompletedTask); + + var expectedOffset = FormatOffset(TimeZoneInfo.Local.GetUtcOffset(new DateTime(2026, 3, 30, 0, 0, 0))); + var tools = new LeaveMcpTools(api, config); + + var json = await tools.CreateLeave( + start: "2026-03-30", + end: "2026-03-30", + type: "1", + note: "Annual leave", + ct: TestContext.Current.CancellationToken); + + using var doc = JsonDocument.Parse(json); + doc.RootElement.GetProperty("success").GetBoolean().Should().BeTrue(); + request.Should().NotBeNull(); + request!.StartDate.Should().Be($"2026-03-30T00:00:00.0000000{expectedOffset}"); + request.EndDate.Should().Be($"2026-03-30T23:59:00.0000000{expectedOffset}"); + } + + [Fact] + public async Task CreateLeave_WhenTimezoneOverrideProvided_UsesOverrideBeforeProfileTimezone() + { + var api = Substitute.For(); + var config = Substitute.For(); + config.LoadActiveTenantConfig().Returns(new TenantConfig + { + TenantId = "test", + ApiUrl = "https://timepro.example", + ApiKey = "test-api-key", + EmployeeId = "TST" + }); + + CreateLeaveRequest? request = null; + api.CreateLeaveAsync(Arg.Do(r => request = r), Arg.Any()) + .Returns(Task.CompletedTask); + + var (timeZoneId, timeZone) = FindTimeZone("Pacific/Auckland", "New Zealand Standard Time", "UTC"); + var expectedOffset = FormatOffset(timeZone.GetUtcOffset(new DateTime(2026, 3, 30, 0, 0, 0))); + var tools = new LeaveMcpTools(api, config); + + var json = await tools.CreateLeave( + start: "2026-03-30", + end: "2026-03-30", + type: "1", + note: "Annual leave", + timeZoneId: timeZoneId, + ct: TestContext.Current.CancellationToken); + + using var doc = JsonDocument.Parse(json); + doc.RootElement.GetProperty("success").GetBoolean().Should().BeTrue(); + request.Should().NotBeNull(); + request!.StartDate.Should().Be($"2026-03-30T00:00:00.0000000{expectedOffset}"); + request.EndDate.Should().Be($"2026-03-30T23:59:00.0000000{expectedOffset}"); + api.ReceivedCalls() + .Should() + .NotContain(call => call.GetMethodInfo().Name == nameof(ITimeProApiClient.GetEmployeeSettingsAsync)); + } + + [Fact] + public async Task CreateLeave_WhenNoteMissing_ReturnsErrorWithoutCallingApi() + { + var api = Substitute.For(); + var config = Substitute.For(); + config.LoadActiveTenantConfig().Returns(new TenantConfig + { + TenantId = "test", + ApiUrl = "https://timepro.example", + ApiKey = "test-api-key", + EmployeeId = "TST" + }); + + var tools = new LeaveMcpTools(api, config); + + var json = await tools.CreateLeave( + start: "2026-03-30", + end: "2026-03-30", + type: "1", + note: " ", + ct: TestContext.Current.CancellationToken); + + using var doc = JsonDocument.Parse(json); + doc.RootElement.GetProperty("error").GetString().Should().Contain("note is required"); + api.ReceivedCalls() + .Should() + .NotContain(call => call.GetMethodInfo().Name == nameof(ITimeProApiClient.CreateLeaveAsync)); + } + + private static (string id, TimeZoneInfo timeZone) FindTimeZone(params string[] ids) + { + foreach (var id in ids) + { + if (TimeZoneInfo.TryFindSystemTimeZoneById(id, out var timeZone)) + return (id, timeZone); + } + + throw new InvalidOperationException("No test timezone was available."); + } + + private static string FormatOffset(TimeSpan offset) + { + var sign = offset < TimeSpan.Zero ? "-" : "+"; + offset = offset.Duration(); + return $"{sign}{offset.Hours:00}:{offset.Minutes:00}"; + } +}