From 4f4158ba600c7e7f0b95ead821db9e69a80c7dfb Mon Sep 17 00:00:00 2001 From: Brady Stroud Date: Wed, 7 Oct 2026 17:32:07 +1100 Subject: [PATCH] feat(leave): set the leave break with --less, and keep a partial day's window on update EasyLeave deducts the employee's lunch break from every leave day, so a 09:00-13:00 half day counts 3 hours. The API takes TimeLessOverride (hours) to replace that deduction, but no CLI or MCP surface could set it. - tp leave create and tp leave update take --less ; update also takes --clear-less to go back to the lunch-break default - MCP CreateLeave and UpdateLeave take lessMinutes; UpdateLeave takes clearLess - Updating a partial-day request no longer widens it to the profile workday. The list response omits UserStartTime/UserEndTime, so the window now comes from the request's own start and end Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rf633EiNySafrMCHZnMM7W --- AGENTS.md | 5 + README.md | 7 +- .../Features/Leave/CreateCommand.cs | 14 ++- .../Features/Leave/LeaveCreateService.cs | 8 +- .../Features/Leave/LeaveUpdateService.cs | 36 ++++++- .../Features/Leave/UpdateCommand.cs | 19 +++- .../Features/Mcp/Tools/LeaveMcpTools.cs | 10 +- .../Skills/Templates/timepro-timesheets.md | 2 + .../Mcp/Discovery/tools-list.accounting.json | 21 ++++ .../Mcp/Discovery/tools-list.default.json | 21 ++++ .../Features/Leave/CreateCommandTests.cs | 33 ++++++ .../Features/Leave/UpdateCommandTests.cs | 100 ++++++++++++++++++ 12 files changed, 264 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8242f39..f851f23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,11 @@ The leave create endpoint (`POST /api/leave/`) requires these fields in the requ - **Required**: `RequestedEmpId`, `StartDate` (DateTimeOffset), `EndDate` (DateTimeOffset), `LeaveTypeId`, `UserStartTime`, `UserEndTime`, `AllDay` - **Optional**: `Note`, `OptionalEmp` (CC emails), `ApprovedBy` (email), `TimeLessOverride` +`TimeLessOverride` is in **hours** and replaces the lunch-break deduction on every leave day +(`0` = no break, `null` = deduct the employee's lunch break). The leave timesheets EasyLeave +creates take their `less` from it too. `--less` and the MCP `lessMinutes` take whole minutes, +like `ts create`, and the services convert. + The leave update endpoint (`PUT /api/leave/`) uses the same full payload plus `Id`. It is a replacement operation, not a patch. All CLI and MCP updates must go through `LeaveUpdateService`, which reads the existing request and preserves omitted API-returned fields diff --git a/README.md b/README.md index f3a0aaf..02755eb 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,9 @@ tp leave create --start 2026-03-30 --end 2026-03-30 --type "Annual Leave" \ tp leave update --start 2026-04-01 --end 2026-04-01 \ --note "Updated travel date" --yes +# Make a 09:00-13:00 half day count as 4 hours by removing the lunch-break deduction +tp leave update --less 0 --yes + # Validate the same update and inspect the complete payload without applying it tp leave update --start 2026-04-01 --end 2026-04-01 \ --note "Updated travel date" --dry-run --json @@ -288,6 +291,7 @@ Leave create options: - `--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 +- `--less` sets the break in whole minutes (`--less 0` for none), replacing the lunch-break deduction 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`. @@ -297,7 +301,8 @@ field that was not explicitly changed. Stored workday times are preserved when T returns them; older list responses omit them, so update falls back to the current profile times and then 09:00-18:00. Use `--start-time` and `--end-time` when those values must be explicit. Use `--clear-approved-by` or `--clear-cc` to remove those values, and -`--half-day` / `--full-day` to change the day mode. Both create and update support +`--half-day` / `--full-day` to change the day mode. `--less ` overrides the break +deducted from each leave day, and `--clear-less` goes back to the lunch-break default. Both create and update support `--dry-run`; combine it with `--json` to inspect the exact API payload without creating or changing leave. diff --git a/src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs b/src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs index 59071d8..fb43d6f 100644 --- a/src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs +++ b/src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using SSW.TimePro.Cli.Features.Timesheets; using SSW.TimePro.Cli.Infrastructure.ApiClient; using SSW.TimePro.Cli.Infrastructure.Output; using Spectre.Console; @@ -50,6 +51,10 @@ public class Settings : CommandSettings [Description("End time override (HH:mm, default: 18:00)")] public string? EndTime { get; set; } + [CommandOption("--less ")] + [Description("Break/less time in whole minutes, e.g. --less 0 for no break. Overrides the lunch-break default")] + public string? Less { get; set; } + [CommandOption("--timezone ")] [Description("Timezone override (IANA or Windows ID); takes priority over the TimePro user profile timezone")] public string? TimeZoneId { get; set; } @@ -84,6 +89,12 @@ protected override async Task ExecuteAsync(CommandContext context, Settings return 1; } + if (!LessOption.TryParse(settings.Less, out var lessMinutes, out var lessError)) + { + WriteValidationError(settings.Json, lessError!); + return 1; + } + try { var plan = await _createService.PrepareAsync( @@ -98,7 +109,8 @@ protected override async Task ExecuteAsync(CommandContext context, Settings HalfDay: settings.HalfDay, StartTime: settings.StartTime, EndTime: settings.EndTime, - TimeZoneId: settings.TimeZoneId), + TimeZoneId: settings.TimeZoneId, + LessMinutes: lessMinutes), cancellationToken); if (!settings.Json && (!settings.Yes || settings.DryRun)) diff --git a/src/SSW.TimePro.Cli/Features/Leave/LeaveCreateService.cs b/src/SSW.TimePro.Cli/Features/Leave/LeaveCreateService.cs index ede3559..ef26a9b 100644 --- a/src/SSW.TimePro.Cli/Features/Leave/LeaveCreateService.cs +++ b/src/SSW.TimePro.Cli/Features/Leave/LeaveCreateService.cs @@ -13,7 +13,8 @@ public sealed record LeaveCreateOptions( bool HalfDay = false, string? StartTime = null, string? EndTime = null, - string? TimeZoneId = null); + string? TimeZoneId = null, + int? LessMinutes = null); public sealed record LeaveCreatePlan(CreateLeaveRequest Request, string TypeLabel); @@ -58,6 +59,9 @@ public async Task PrepareAsync( throw new LeaveCreateValidationException("--start, --end, and --type are required"); } + if (options.LessMinutes is < 0) + throw new LeaveCreateValidationException("Break/less time must be zero or greater"); + if (string.IsNullOrWhiteSpace(options.Note)) { throw new LeaveCreateValidationException( @@ -140,7 +144,7 @@ public async Task PrepareAsync( AllDay = allDay, OptionalEmp = LeaveRequestParser.ParseOptionalEmployees(options.Cc), ApprovedBy = options.ApprovedBy?.Trim(), - TimeLessOverride = null + TimeLessOverride = options.LessMinutes / 60m }; return new LeaveCreatePlan(request, options.Type); diff --git a/src/SSW.TimePro.Cli/Features/Leave/LeaveUpdateService.cs b/src/SSW.TimePro.Cli/Features/Leave/LeaveUpdateService.cs index 4da959f..62a31f4 100644 --- a/src/SSW.TimePro.Cli/Features/Leave/LeaveUpdateService.cs +++ b/src/SSW.TimePro.Cli/Features/Leave/LeaveUpdateService.cs @@ -15,7 +15,9 @@ public sealed record LeaveUpdateOptions( bool? AllDay = null, string? StartTime = null, string? EndTime = null, - string? TimeZoneId = null); + string? TimeZoneId = null, + int? LessMinutes = null, + bool ClearLess = false); public sealed record LeaveUpdatePlan( LeaveEntry Existing, @@ -56,6 +58,12 @@ public async Task PrepareAsync( if (options.ClearCc && options.Cc is not null) throw new LeaveUpdateValidationException("Use either cc or clearCc, not both"); + if (options.ClearLess && options.LessMinutes is not null) + throw new LeaveUpdateValidationException("Use either less or clearLess, not both"); + + if (options.LessMinutes is < 0) + throw new LeaveUpdateValidationException("Break/less time must be zero or greater"); + var normalizedLeaveId = parsedLeaveId.ToString(); var existing = await _lookup.FindAsync(normalizedLeaveId, employeeId, ct) ?? throw new LeaveUpdateValidationException( @@ -69,7 +77,7 @@ public async Task PrepareAsync( var changes = DescribeChanges(options); if (changes.Count == 0) throw new LeaveUpdateValidationException( - "No changes specified. Use --start, --end, --type, --note, --approved-by, --cc, --half-day, --full-day, --start-time, or --end-time."); + "No changes specified. Use --start, --end, --type, --note, --approved-by, --cc, --half-day, --full-day, --start-time, --end-time, --less, or --clear-less."); var employeeSettings = await _api.GetEmployeeSettingsAsync(ct); if (!LeaveRequestParser.TryResolveRequestTimeZone( @@ -101,8 +109,14 @@ public async Task PrepareAsync( var allDay = options.AllDay ?? existing.AllDay; + // A partial-day request's window is its stored start and end. The list response omits + // UserStartTime/UserEndTime, and the profile workday would silently widen the window. + var keepsPartialWindow = !existing.AllDay && !allDay; + var existingWindowStart = keepsPartialWindow ? TimePart(existing.StartDateLocal) : null; + var existingWindowEnd = keepsPartialWindow ? TimePart(existing.EndDateLocal) : null; + if (!LeaveRequestParser.TryParseWorkdayTime( - options.StartTime ?? existing.UserStartTime ?? employeeSettings?.StartTime, + options.StartTime ?? existing.UserStartTime ?? existingWindowStart ?? employeeSettings?.StartTime, LeaveRequestParser.DefaultStartTime, "start", out var userStartTime, @@ -112,7 +126,7 @@ public async Task PrepareAsync( } if (!LeaveRequestParser.TryParseWorkdayTime( - options.EndTime ?? existing.UserEndTime ?? employeeSettings?.EndTime, + options.EndTime ?? existing.UserEndTime ?? existingWindowEnd ?? employeeSettings?.EndTime, LeaveRequestParser.DefaultEndTime, "end", out var userEndTime, @@ -184,7 +198,11 @@ options.Type is null AllDay = allDay, OptionalEmp = optionalEmployees, ApprovedBy = approvedBy, - TimeLessOverride = existing.TimeLessOverride + TimeLessOverride = options.ClearLess + ? null + : options.LessMinutes is { } lessMinutes + ? lessMinutes / 60m + : existing.TimeLessOverride }; return new LeaveUpdatePlan(existing, request, changes); @@ -217,11 +235,19 @@ private static List DescribeChanges(LeaveUpdateOptions options) if (options.AllDay is not null) changes.Add(options.AllDay.Value ? "Day mode -> full day" : "Day mode -> partial day"); if (options.StartTime is not null) changes.Add($"Workday start -> {options.StartTime}"); if (options.EndTime is not null) changes.Add($"Workday end -> {options.EndTime}"); + if (options.ClearLess) changes.Add("Break -> lunch-break default"); + else if (options.LessMinutes is not null) changes.Add($"Break -> {options.LessMinutes} min"); return changes; } private static string? DatePart(string? value) => value?.Split('T')[0]; + private static string? TimePart(string? value) + { + var parts = value?.Split('T'); + return parts is { Length: 2 } && parts[1].Length >= 5 ? parts[1][..5] : null; + } + private static string Truncate(string value) => value.Length > 50 ? value[..50] + "..." : value; diff --git a/src/SSW.TimePro.Cli/Features/Leave/UpdateCommand.cs b/src/SSW.TimePro.Cli/Features/Leave/UpdateCommand.cs index 26fcf7b..53dfb30 100644 --- a/src/SSW.TimePro.Cli/Features/Leave/UpdateCommand.cs +++ b/src/SSW.TimePro.Cli/Features/Leave/UpdateCommand.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using SSW.TimePro.Cli.Features.Timesheets; using SSW.TimePro.Cli.Infrastructure.ApiClient; using SSW.TimePro.Cli.Infrastructure.Config; using SSW.TimePro.Cli.Infrastructure.Output; @@ -67,6 +68,14 @@ public class Settings : CommandSettings [Description("Employee workday end time (HH:mm)")] public string? EndTime { get; set; } + [CommandOption("--less ")] + [Description("Break/less time in whole minutes, e.g. --less 0 for no break. Overrides the lunch-break default")] + public string? Less { get; set; } + + [CommandOption("--clear-less")] + [Description("Remove the break override and use the lunch-break default again")] + public bool ClearLess { get; set; } + [CommandOption("--timezone ")] [Description("Timezone for changed dates (IANA or Windows ID)")] public string? TimeZoneId { get; set; } @@ -108,6 +117,12 @@ protected override async Task ExecuteAsync( return 1; } + if (!LessOption.TryParse(settings.Less, out var lessMinutes, out var lessError)) + { + WriteValidationError(settings.Json, lessError!); + return 1; + } + try { var plan = await _updateService.PrepareAsync( @@ -125,7 +140,9 @@ protected override async Task ExecuteAsync( AllDay: settings.HalfDay ? false : settings.FullDay ? true : null, StartTime: settings.StartTime, EndTime: settings.EndTime, - TimeZoneId: settings.TimeZoneId), + TimeZoneId: settings.TimeZoneId, + LessMinutes: lessMinutes, + ClearLess: settings.ClearLess), cancellationToken); if (!settings.Json) diff --git a/src/SSW.TimePro.Cli/Features/Mcp/Tools/LeaveMcpTools.cs b/src/SSW.TimePro.Cli/Features/Mcp/Tools/LeaveMcpTools.cs index 437eb9f..693a7ba 100644 --- a/src/SSW.TimePro.Cli/Features/Mcp/Tools/LeaveMcpTools.cs +++ b/src/SSW.TimePro.Cli/Features/Mcp/Tools/LeaveMcpTools.cs @@ -106,6 +106,7 @@ public async Task CreateLeave( [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, + [Description("Break/less time in whole minutes (0 for no break); omit to use the lunch-break default")] int? lessMinutes = null, [Description("Validate and return the proposed request without creating leave")] bool dryRun = false, CancellationToken ct = default) { @@ -127,7 +128,8 @@ public async Task CreateLeave( HalfDay: halfDay, StartTime: startTime, EndTime: endTime, - TimeZoneId: timeZoneId), + TimeZoneId: timeZoneId, + LessMinutes: lessMinutes), ct); if (dryRun) @@ -158,6 +160,8 @@ public async Task UpdateLeave( [Description("Employee workday start time (HH:mm); omit to preserve it when returned, otherwise use the profile value")] string? startTime = null, [Description("Employee workday end time (HH:mm); omit to preserve it when returned, otherwise use the profile value")] string? endTime = null, [Description("Timezone for changed dates (IANA or Windows ID)")] string? timeZoneId = null, + [Description("Break/less time in whole minutes (0 for no break); omit to preserve")] int? lessMinutes = null, + [Description("Remove the break override and use the lunch-break default again")] bool clearLess = false, [Description("Validate and return the proposed update without applying it")] bool dryRun = false, CancellationToken ct = default) { @@ -182,7 +186,9 @@ public async Task UpdateLeave( AllDay: halfDay is null ? null : !halfDay.Value, StartTime: startTime, EndTime: endTime, - TimeZoneId: timeZoneId), + TimeZoneId: timeZoneId, + LessMinutes: lessMinutes, + ClearLess: clearLess), ct); if (dryRun) 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 ac750ad..9f5d6ba 100644 --- a/src/SSW.TimePro.Cli/Features/Skills/Templates/timepro-timesheets.md +++ b/src/SSW.TimePro.Cli/Features/Skills/Templates/timepro-timesheets.md @@ -70,6 +70,8 @@ tp leave create --start 2026-03-30 --end 2026-03-30 --type 1 \ tp leave create --start 2026-03-30 --end 2026-03-30 --type 1 --note "Reason" --dry-run --json tp leave update --start 2026-04-01 --end 2026-04-01 --note "Updated reason" --yes tp leave update --note "Updated reason" --dry-run --json +# Leave deducts the lunch break, so a 09:00-13:00 half day counts 3 hours. --less 0 removes it. +tp leave update --less 0 --yes tp leave cancel --reason "Plans changed" --yes --wait ``` diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Discovery/tools-list.accounting.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Discovery/tools-list.accounting.json index 1b38ed9..fd4b5b2 100644 --- a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Discovery/tools-list.accounting.json +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Discovery/tools-list.accounting.json @@ -130,6 +130,14 @@ "description": "Request partial-day leave; start and end must be the same day", "type": "boolean" }, + "lessMinutes": { + "default": null, + "description": "Break/less time in whole minutes (0 for no break); omit to use the lunch-break default", + "type": [ + "integer", + "null" + ] + }, "note": { "description": "Leave note/reason", "type": "string" @@ -1376,6 +1384,11 @@ "description": "Remove all current CC recipients", "type": "boolean" }, + "clearLess": { + "default": false, + "description": "Remove the break override and use the lunch-break default again", + "type": "boolean" + }, "dryRun": { "default": false, "description": "Validate and return the proposed update without applying it", @@ -1409,6 +1422,14 @@ "description": "Leave entry ID (GUID)", "type": "string" }, + "lessMinutes": { + "default": null, + "description": "Break/less time in whole minutes (0 for no break); omit to preserve", + "type": [ + "integer", + "null" + ] + }, "note": { "default": null, "description": "New leave note/reason; omit to preserve", diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Discovery/tools-list.default.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Discovery/tools-list.default.json index 14ca3b2..5ec80bf 100644 --- a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Discovery/tools-list.default.json +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Discovery/tools-list.default.json @@ -130,6 +130,14 @@ "description": "Request partial-day leave; start and end must be the same day", "type": "boolean" }, + "lessMinutes": { + "default": null, + "description": "Break/less time in whole minutes (0 for no break); omit to use the lunch-break default", + "type": [ + "integer", + "null" + ] + }, "note": { "description": "Leave note/reason", "type": "string" @@ -592,6 +600,11 @@ "description": "Remove all current CC recipients", "type": "boolean" }, + "clearLess": { + "default": false, + "description": "Remove the break override and use the lunch-break default again", + "type": "boolean" + }, "dryRun": { "default": false, "description": "Validate and return the proposed update without applying it", @@ -625,6 +638,14 @@ "description": "Leave entry ID (GUID)", "type": "string" }, + "lessMinutes": { + "default": null, + "description": "Break/less time in whole minutes (0 for no break); omit to preserve", + "type": [ + "integer", + "null" + ] + }, "note": { "default": null, "description": "New leave note/reason; omit to preserve", diff --git a/tests/SSW.TimePro.Cli.Tests/Features/Leave/CreateCommandTests.cs b/tests/SSW.TimePro.Cli.Tests/Features/Leave/CreateCommandTests.cs index dccc5a7..5371ca8 100644 --- a/tests/SSW.TimePro.Cli.Tests/Features/Leave/CreateCommandTests.cs +++ b/tests/SSW.TimePro.Cli.Tests/Features/Leave/CreateCommandTests.cs @@ -105,6 +105,39 @@ public async Task Create_WhenProfileTimezoneAvailable_SendsDateOffsetsFromProfil request.Note.Should().Be("Annual leave"); } + [Theory] + [InlineData(null, null)] + [InlineData("0", 0.0)] + [InlineData("30", 0.5)] + public async Task Create_WhenLessGiven_SendsBreakOverrideInHours(string? less, double? expectedHours) + { + var api = Substitute.For(); + CreateLeaveRequest? request = null; + api.CreateLeaveAsync(Arg.Do(r => request = r), Arg.Any()) + .Returns(Task.CompletedTask); + api.GetEmployeeSettingsAsync(Arg.Any()) + .Returns(new EmployeeSettings { TimezoneId = "UTC" }); + var app = CreateApp(api); + string[] lessArgs = less is null ? [] : ["--less", less]; + + var exitCode = await app.RunAsync([ + "create", + "--start", "2026-03-30", + "--end", "2026-03-30", + "--type", "1", + "--note", "Half day", + "--half-day", + "--start-time", "09:00", + "--end-time", "13:00", + .. lessArgs, + "--json" + ], TestContext.Current.CancellationToken); + + exitCode.Should().Be(0); + request.Should().NotBeNull(); + request!.TimeLessOverride.Should().Be((decimal?)expectedHours); + } + [Fact] public async Task Create_WhenProfileTimezoneMissing_UsesMachineTimezone() { diff --git a/tests/SSW.TimePro.Cli.Tests/Features/Leave/UpdateCommandTests.cs b/tests/SSW.TimePro.Cli.Tests/Features/Leave/UpdateCommandTests.cs index ab58b61..0b49fca 100644 --- a/tests/SSW.TimePro.Cli.Tests/Features/Leave/UpdateCommandTests.cs +++ b/tests/SSW.TimePro.Cli.Tests/Features/Leave/UpdateCommandTests.cs @@ -419,6 +419,106 @@ public async Task Update_WhenDryRunOnATerminalStatus_FailsBeforePreparingAPayloa api.ShouldNotHaveReceived(nameof(ITimeProApiClient.GetEmployeeSettingsAsync)); } + [Theory] + [InlineData("0", 0)] + [InlineData("30", 0.5)] + [InlineData("60", 1)] + public async Task Update_WhenLessGiven_SendsBreakOverrideInHours(string less, double expectedHours) + { + var api = Substitute.For(); + ConfigureExistingLeave(api, allDay: false); + UpdateLeaveRequest? request = null; + api.UpdateLeaveAsync(Arg.Do(value => request = value), Arg.Any()) + .Returns(Task.CompletedTask); + var app = CreateApp(api); + + var exitCode = await app.RunAsync([ + "update", + LeaveId, + "--less", less, + "--json" + ], TestContext.Current.CancellationToken); + + exitCode.Should().Be(0); + request.Should().NotBeNull(); + request!.TimeLessOverride.Should().Be((decimal)expectedHours); + request.Note.Should().Be("Original plans"); + } + + [Fact] + public async Task Update_WhenPartialDayHasNoStoredWorkdayTimes_KeepsItsWindow() + { + var api = Substitute.For(); + ConfigureExistingLeave(api, allDay: false); + var existing = (await api.GetLeaveAsync("UPCOMING", 1, 100, "TST", TestContext.Current.CancellationToken))! + .Leaves!.Items[0]; + existing.StartDate = "2026-03-30T09:00:00+00:00"; + existing.EndDate = "2026-03-30T13:00:00+00:00"; + existing.StartDateLocal = "2026-03-30T09:00:00"; + existing.EndDateLocal = "2026-03-30T13:00:00"; + existing.UserStartTime = null; + existing.UserEndTime = null; + UpdateLeaveRequest? request = null; + api.UpdateLeaveAsync(Arg.Do(value => request = value), Arg.Any()) + .Returns(Task.CompletedTask); + var app = CreateApp(api); + + var exitCode = await app.RunAsync([ + "update", + LeaveId, + "--less", "0", + "--json" + ], TestContext.Current.CancellationToken); + + exitCode.Should().Be(0); + request.Should().NotBeNull(); + request!.UserStartTime.Should().Be("09:00:00"); + request.UserEndTime.Should().Be("13:00:00"); + request.StartDate.Should().Be("2026-03-30T09:00:00.0000000+00:00"); + request.EndDate.Should().Be("2026-03-30T13:00:00.0000000+00:00"); + request.TimeLessOverride.Should().Be(0m); + } + + [Fact] + public async Task Update_WhenClearLess_SendsNullBreakOverride() + { + var api = Substitute.For(); + ConfigureExistingLeave(api); + UpdateLeaveRequest? request = null; + api.UpdateLeaveAsync(Arg.Do(value => request = value), Arg.Any()) + .Returns(Task.CompletedTask); + var app = CreateApp(api); + + var exitCode = await app.RunAsync([ + "update", + LeaveId, + "--clear-less", + "--json" + ], TestContext.Current.CancellationToken); + + exitCode.Should().Be(0); + request.Should().NotBeNull(); + request!.TimeLessOverride.Should().BeNull(); + } + + [Theory] + [InlineData("--less", "abc")] + [InlineData("--less", "-15")] + [InlineData("--less", "30", "--clear-less")] + public async Task Update_WhenLessInvalid_ReturnsErrorWithoutCallingUpdateApi(params string[] args) + { + var api = Substitute.For(); + ConfigureExistingLeave(api); + var app = CreateApp(api); + + var exitCode = await app.RunAsync( + ["update", LeaveId, .. args, "--json"], + TestContext.Current.CancellationToken); + + exitCode.Should().Be(1); + api.ShouldNotHaveReceived(nameof(ITimeProApiClient.UpdateLeaveAsync)); + } + private static void ConfigureExistingLeave(ITimeProApiClient api, bool allDay = true, int status = 2) { api.GetLeaveAsync(