diff --git a/AGENTS.md b/AGENTS.md index 179f440..05fecc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,6 +143,16 @@ stay apart on purpose — `rate get --json` answers a miss with the full `RateLo while the MCP tool still returns the raw response and a bare `null`; aligning them is a 0.4.0 contract change, not a patch. +### Shared timesheet reads + +`TimesheetLookup.ForRangeAsync` is the one per-day range read. The weekend policy is its explicit +`WeekendPolicy` argument, not a duplicated loop: `ts get` includes Saturday and Sunday, the MCP +`GetTimesheets` tool skips them (a weekend-only range answers empty without calling the API). +`TimesheetLookup.RefreshAndReadSuggestedAsync` is the refresh-then-read behind `ts suggest` and +`GetSuggestedTimesheets`; the refresh is a server-side write, so it happens once, in there. +`WeekCheckResult` is the week-coverage document both `ts check --json` and `CheckWeek` serialise. +The projections that remain per-surface are envelope shapes only, declared in the parity table. + ### `--json` error envelope On the `--json` path, failures emit a structured envelope to **stdout** so stdout stays valid JSON: `{"error":{"code":,"message":"...","detail":}}` (all keys always present), with a non-zero exit code. Human-readable error/warning text goes to **stderr**. @@ -306,6 +316,9 @@ tests fail until you do. - `McpStdioClient` launches the real `tp mcp` with `TIMEPRO_CLI_CONFIG_DIR` pointing at a throwaway config. `Goldens/Mcp/Discovery/` holds the `tools/list` snapshots with accounting off (18 tools) and on (47); `Goldens/Mcp/Calls/` holds `tools/call` envelopes. +- `TimesheetToolsUsingApiDirectly` is the shrink-only allowlist of timesheet tools still calling + `ITimeProApiClient` themselves; `ToolIlScanner` reads the tools' IL (constructor inspection cannot + answer it, since the shared services take the client as an argument). - `McpCliParityTable` pairs every tool with its CLI command. Differences are declared per case as JSON paths — there is no generic normalisation — and `ExpectParity` flips to true as each slice lands. `ToolsWithoutCliMirror` may only shrink. diff --git a/src/SSW.TimePro.Cli/Features/Mcp/Tools/TimesheetMcpTools.cs b/src/SSW.TimePro.Cli/Features/Mcp/Tools/TimesheetMcpTools.cs index f5699e8..d35b13e 100644 --- a/src/SSW.TimePro.Cli/Features/Mcp/Tools/TimesheetMcpTools.cs +++ b/src/SSW.TimePro.Cli/Features/Mcp/Tools/TimesheetMcpTools.cs @@ -61,22 +61,18 @@ public async Task GetTimesheets( ? DateOnly.ParseExact(endDate, "yyyy-MM-dd") : start; - var allTimesheets = new List(); - for (var d = start; d <= end; d = d.AddDays(1)) + // Weekends are skipped, so a weekend-only range answers with an empty array and no call. + var days = await TimesheetLookup.ForRangeAsync( + _api, targetEmpId, start, end, WeekendPolicy.Skip, ct); + + var allTimesheets = days.SelectMany(day => day.Entries.Select(t => new { - if (d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) - continue; - - var dayTimesheets = await _api.GetTimesheetsAsync(targetEmpId, d, ct); - allTimesheets.AddRange(dayTimesheets.Select(t => new - { - t.TimeId, t.EmpId, t.EmpName, t.Client, t.ClientId, t.Project, t.ProjectId, - date = d.ToString("yyyy-MM-dd"), - t.StartTime, t.EndTime, t.TotalTime, - t.Location, t.BillableId, t.IsSuggested, - t.Notes, t.IsLocked, t.InvoiceId - })); - } + t.TimeId, t.EmpId, t.EmpName, t.Client, t.ClientId, t.Project, t.ProjectId, + date = day.Date.ToString("yyyy-MM-dd"), + t.StartTime, t.EndTime, t.TotalTime, + t.Location, t.BillableId, t.IsSuggested, + t.Notes, t.IsLocked, t.InvoiceId + })).ToList(); return JsonSerializer.Serialize(allTimesheets, JsonOpts); } @@ -229,32 +225,7 @@ public async Task CheckWeek( // Shared orchestration with `tp ts check` — fetch + leave-merge + per-day eval. var coverage = await WeekCoverageService.EvaluateWeekAsync(_api, targetEmpId, week, ct); - var result = new - { - empId = coverage.EmpId, - weekStart = coverage.Monday.ToString("yyyy-MM-dd"), - weekEnd = coverage.Friday.ToString("yyyy-MM-dd"), - errors = coverage.Errors, - warnings = coverage.Warnings, - infos = coverage.Infos, - allCovered = coverage.AllCovered, - pendingSuggestions = CheckEvaluator.CountPendingSuggestions(coverage.Days), - days = coverage.Days.Select(check => new - { - date = check.Date.ToString("yyyy-MM-dd"), - dayOfWeek = check.Date.DayOfWeek.ToString(), - totalHours = check.TotalHours, - timesheetCount = check.TimesheetCount, - suggestedCount = check.SuggestedCount, - leaveHours = check.LeaveHours, - leaveType = check.LeaveType, - covered = check.Covered, - coverReason = check.CoverReason, - issues = check.Issues.Select(i => new { i.Severity, i.Message }) - }) - }; - - return JsonSerializer.Serialize(result, JsonOpts); + return JsonSerializer.Serialize(WeekCheckResult.From(coverage), JsonOpts); } [McpServerTool] @@ -267,13 +238,10 @@ public async Task GetSuggestedTimesheets( if (tenant?.EmployeeId is null) return """{"error": "Not logged in"}"""; - var dateOnly = DateOnly.ParseExact(date, "yyyy-MM-dd"); - await _api.RefreshSuggestedTimesheetsAsync(tenant.EmployeeId, dateOnly, ct); - - var all = await _api.GetTimesheetsAsync(tenant.EmployeeId, dateOnly, ct); - var suggested = all.Where(t => t.IsSuggested).ToList(); + var day = await TimesheetLookup.RefreshAndReadSuggestedAsync( + _api, tenant.EmployeeId, DateOnly.ParseExact(date, "yyyy-MM-dd"), ct); - return JsonSerializer.Serialize(suggested, JsonOpts); + return JsonSerializer.Serialize(day.Entries, JsonOpts); } [McpServerTool] diff --git a/src/SSW.TimePro.Cli/Features/Timesheets/CheckCommand.cs b/src/SSW.TimePro.Cli/Features/Timesheets/CheckCommand.cs index 0f54668..8f23e18 100644 --- a/src/SSW.TimePro.Cli/Features/Timesheets/CheckCommand.cs +++ b/src/SSW.TimePro.Cli/Features/Timesheets/CheckCommand.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using System.Text.Json.Serialization; using SSW.TimePro.Cli.Infrastructure.ApiClient; using SSW.TimePro.Cli.Infrastructure.Config; using SSW.TimePro.Cli.Infrastructure.Output; @@ -59,42 +58,13 @@ protected override async Task ExecuteAsync(CommandContext context, Settings var coverage = await WeekCoverageService.EvaluateWeekAsync(_api, empId, offset, cancellationToken); var dayChecks = coverage.Days; - var errors = coverage.Errors; - var warnings = coverage.Warnings; - var infos = coverage.Infos; - var allCovered = coverage.AllCovered; var monday = coverage.Monday; var friday = coverage.Friday; - var dayResults = dayChecks.Select(check => new DayJson - { - Date = check.Date.ToString("yyyy-MM-dd"), - DayOfWeek = check.Date.DayOfWeek.ToString(), - TotalHours = check.TotalHours, - TimesheetCount = check.TimesheetCount, - SuggestedCount = check.SuggestedCount, - LeaveHours = check.LeaveHours, - LeaveType = check.LeaveType, - Covered = check.Covered, - CoverReason = check.CoverReason, - Issues = check.Issues.Select(i => new IssueJson(i.Severity, i.Message)).ToList() - }).ToList(); - - var pendingSuggestions = CheckEvaluator.CountPendingSuggestions(dayChecks); - var summary = CheckEvaluator.Summarize(errors, warnings, infos, pendingSuggestions, allCovered, settings.Strict); - - var result = new - { - empId, - weekStart = monday.ToString("yyyy-MM-dd"), - weekEnd = friday.ToString("yyyy-MM-dd"), - errors, - warnings, - infos, - allCovered, - pendingSuggestions, - days = dayResults - }; + var result = WeekCheckResult.From(coverage); + var summary = CheckEvaluator.Summarize( + coverage.Errors, coverage.Warnings, coverage.Infos, + result.PendingSuggestions, coverage.AllCovered, settings.Strict); OutputHelper.Render(result, settings.Json, _ => { @@ -162,24 +132,4 @@ protected override async Task ExecuteAsync(CommandContext context, Settings private static string ResolveEmpId(string? requestedEmpId, string defaultEmpId) => string.IsNullOrWhiteSpace(requestedEmpId) ? defaultEmpId : requestedEmpId.Trim(); - - /// Per-day JSON shape. is always emitted (null when no leave). - private sealed class DayJson - { - public string Date { get; init; } = string.Empty; - public string DayOfWeek { get; init; } = string.Empty; - public decimal TotalHours { get; init; } - public int TimesheetCount { get; init; } - public int SuggestedCount { get; init; } - public decimal LeaveHours { get; init; } - - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string? LeaveType { get; init; } - - public bool Covered { get; init; } - public string CoverReason { get; init; } = string.Empty; - public IReadOnlyList Issues { get; init; } = []; - } - - private sealed record IssueJson(string Severity, string Message); } diff --git a/src/SSW.TimePro.Cli/Features/Timesheets/GetCommand.cs b/src/SSW.TimePro.Cli/Features/Timesheets/GetCommand.cs index e470866..5ae1889 100644 --- a/src/SSW.TimePro.Cli/Features/Timesheets/GetCommand.cs +++ b/src/SSW.TimePro.Cli/Features/Timesheets/GetCommand.cs @@ -132,22 +132,20 @@ private async Task RenderDay(string empId, DateOnly date, Settings settings private async Task RenderRange(string empId, DateOnly start, DateOnly end, Settings settings, bool isWeek) { - // Fetch all days in range - var allTimesheets = new Dictionary>(); - for (var d = start; d <= end; d = d.AddDays(1)) - { - var dayTimesheets = await _api.GetTimesheetsAsync(empId, d, CancellationToken.None); - allTimesheets[d] = dayTimesheets; - } + // The CLI shows weekend work; the MCP GetTimesheets tool skips it. + var fetched = await TimesheetLookup.ForRangeAsync( + _api, empId, start, end, WeekendPolicy.Include, CancellationToken.None); + + var allTimesheets = fetched.ToDictionary(d => d.Date, d => d.Entries); if (settings.Json) { - var days = allTimesheets.Select(kvp => new + var days = fetched.Select(day => new { - date = kvp.Key.ToString("yyyy-MM-dd"), - dayOfWeek = kvp.Key.DayOfWeek.ToString(), - timesheets = kvp.Value, - totalHours = kvp.Value.Where(t => !t.IsSuggested).Sum(t => t.TotalTime) + date = day.Date.ToString("yyyy-MM-dd"), + dayOfWeek = day.Date.DayOfWeek.ToString(), + timesheets = day.Entries, + totalHours = day.Entries.Where(t => !t.IsSuggested).Sum(t => t.TotalTime) }).ToList(); object jsonData = isWeek @@ -170,7 +168,7 @@ private async Task RenderRange(string empId, DateOnly start, DateOnly end, return 0; } - private void RenderWeekCompact(DateOnly start, DateOnly end, Dictionary> allTimesheets) + private void RenderWeekCompact(DateOnly start, DateOnly end, IReadOnlyDictionary> allTimesheets) { AnsiConsole.WriteLine(); AnsiConsole.MarkupLine($" [bold]Week of {start:MMM d} - {end:MMM d, yyyy}[/]"); @@ -228,7 +226,7 @@ private void RenderWeekCompact(DateOnly start, DateOnly end, Dictionary> allTimesheets) + private void RenderWeekDetailed(DateOnly start, DateOnly end, IReadOnlyDictionary> allTimesheets) { foreach (var (date, timesheets) in allTimesheets.OrderBy(x => x.Key)) { @@ -236,7 +234,7 @@ private void RenderWeekDetailed(DateOnly start, DateOnly end, Dictionary timesheets) + private void RenderDayDetailed(DateOnly date, IReadOnlyList timesheets) { var realTimesheets = timesheets.Where(t => !t.IsSuggested).ToList(); var dayTotal = realTimesheets.Sum(t => t.TotalTime); diff --git a/src/SSW.TimePro.Cli/Features/Timesheets/SuggestCommand.cs b/src/SSW.TimePro.Cli/Features/Timesheets/SuggestCommand.cs index 35f77b3..7564eaa 100644 --- a/src/SSW.TimePro.Cli/Features/Timesheets/SuggestCommand.cs +++ b/src/SSW.TimePro.Cli/Features/Timesheets/SuggestCommand.cs @@ -68,18 +68,14 @@ protected override async Task ExecuteAsync(CommandContext context, Settings dates.Add(date); } - var allSuggested = new List<(DateOnly Date, List Items)>(); + var allSuggested = new List(); foreach (var date in dates) { - // Refresh suggested timesheets first - await _api.RefreshSuggestedTimesheetsAsync(tenant.EmployeeId, date, CancellationToken.None); - - // Fetch all timesheets and filter to suggested - var all = await _api.GetTimesheetsAsync(tenant.EmployeeId, date, CancellationToken.None); - var suggested = all.Where(t => t.IsSuggested).ToList(); - if (suggested.Count > 0) - allSuggested.Add((date, suggested)); + var day = await TimesheetLookup.RefreshAndReadSuggestedAsync( + _api, tenant.EmployeeId, date, CancellationToken.None); + if (day.Entries.Count > 0) + allSuggested.Add(day); } if (settings.Json) @@ -87,7 +83,7 @@ protected override async Task ExecuteAsync(CommandContext context, Settings OutputHelper.WriteJson(allSuggested.Select(d => new { date = d.Date.ToString("yyyy-MM-dd"), - suggested = d.Items + suggested = d.Entries })); return 0; } diff --git a/src/SSW.TimePro.Cli/Features/Timesheets/TimesheetLookup.cs b/src/SSW.TimePro.Cli/Features/Timesheets/TimesheetLookup.cs index e80eafe..91c2193 100644 --- a/src/SSW.TimePro.Cli/Features/Timesheets/TimesheetLookup.cs +++ b/src/SSW.TimePro.Cli/Features/Timesheets/TimesheetLookup.cs @@ -6,6 +6,13 @@ namespace SSW.TimePro.Cli.Features.Timesheets; public sealed record TimesheetDay(DateOnly Date, IReadOnlyList Entries); +/// Whether a date range read covers Saturday and Sunday. The two surfaces answer differently. +public enum WeekendPolicy +{ + Include, + Skip +} + /// /// Finding an entry and reading one back after a write. Both the read-merge update path and the /// empty-body write responses need a day's worth of entries, so the lookups are shared. @@ -14,6 +21,48 @@ public static class TimesheetLookup { private const int SearchDays = 28; + /// + /// Reads every day in .. in order. Callers pass + /// their own policy: ts get shows weekend work, the MCP + /// GetTimesheets tool skips it. + /// + public static async Task> ForRangeAsync( + ITimeProApiClient api, + string empId, + DateOnly start, + DateOnly end, + WeekendPolicy weekends, + CancellationToken ct = default) + { + var days = new List(); + for (var d = start; d <= end; d = d.AddDays(1)) + { + if (weekends == WeekendPolicy.Skip && d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) + continue; + + days.Add(new TimesheetDay(d, await api.GetTimesheetsAsync(empId, d, ct))); + } + + return days; + } + + /// + /// The suggestion read behind ts suggest and the MCP GetSuggestedTimesheets tool. + /// The refresh is a server-side write that regenerates the day's suggestions, so it happens + /// exactly once here rather than once per surface. + /// + public static async Task RefreshAndReadSuggestedAsync( + ITimeProApiClient api, + string empId, + DateOnly date, + CancellationToken ct = default) + { + await api.RefreshSuggestedTimesheetsAsync(empId, date, ct); + + var entries = await api.GetTimesheetsAsync(empId, date, ct); + return new TimesheetDay(date, entries.Where(t => t.IsSuggested).ToList()); + } + public static async Task<(TimesheetItem Item, TimesheetDay Day)?> FindAsync( ITimeProApiClient api, string empId, diff --git a/src/SSW.TimePro.Cli/Features/Timesheets/WeekCheckResult.cs b/src/SSW.TimePro.Cli/Features/Timesheets/WeekCheckResult.cs new file mode 100644 index 0000000..b48a6b5 --- /dev/null +++ b/src/SSW.TimePro.Cli/Features/Timesheets/WeekCheckResult.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace SSW.TimePro.Cli.Features.Timesheets; + +/// +/// The week-coverage document shared by ts check --json and the MCP CheckWeek tool, so the +/// two surfaces cannot drift into describing the same coverage differently. +/// +public sealed record WeekCheckResult( + string EmpId, + string WeekStart, + string WeekEnd, + int Errors, + int Warnings, + int Infos, + bool AllCovered, + int PendingSuggestions, + IReadOnlyList Days) +{ + public static WeekCheckResult From(WeekCoverageService.WeekCoverage coverage) => new( + coverage.EmpId, + coverage.Monday.ToString("yyyy-MM-dd"), + coverage.Friday.ToString("yyyy-MM-dd"), + coverage.Errors, + coverage.Warnings, + coverage.Infos, + coverage.AllCovered, + CheckEvaluator.CountPendingSuggestions(coverage.Days), + coverage.Days.Select(WeekCheckDay.From).ToList()); +} + +public sealed record WeekCheckDay( + string Date, + string DayOfWeek, + decimal TotalHours, + int TimesheetCount, + int SuggestedCount, + decimal LeaveHours, + // Always emitted, null when the day has no leave, so consumers can read it unconditionally. + [property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] string? LeaveType, + bool Covered, + string CoverReason, + IReadOnlyList Issues) +{ + public static WeekCheckDay From(CheckEvaluator.DayCheck check) => new( + check.Date.ToString("yyyy-MM-dd"), + check.Date.DayOfWeek.ToString(), + check.TotalHours, + check.TimesheetCount, + check.SuggestedCount, + check.LeaveHours, + check.LeaveType, + check.Covered, + check.CoverReason, + check.Issues.Select(i => new WeekCheckIssue(i.Severity, i.Message)).ToList()); +} + +public sealed record WeekCheckIssue(string Severity, string Message); diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetSuggestedTimesheets.18.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetSuggestedTimesheets.18.cli.json new file mode 100644 index 0000000..f45857c --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetSuggestedTimesheets.18.cli.json @@ -0,0 +1,33 @@ +[ + { + "date": "2026-03-16", + "suggested": [ + { + "billableId": "B", + "category": "Web development", + "client": "Northwind Traders", + "clientId": "NWIND", + "date": "2026-03-16T00:00:00", + "empId": "BOB", + "empName": "Bob Northwind", + "endTime": "2026-03-16T14:00:00", + "hasNotes": true, + "isBillable": true, + "isLeave": false, + "isLocked": false, + "isSuggested": true, + "iteration": "Order history", + "iterationId": 3403, + "less": 0, + "location": "SSW", + "locationId": "SSW", + "notes": "Order history", + "project": "Northwind Traders", + "projectId": "1I776Q", + "startTime": "2026-03-16T13:00:00", + "timeId": 4243, + "totalTime": 1 + } + ] + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetSuggestedTimesheets.18.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetSuggestedTimesheets.18.mcp.json new file mode 100644 index 0000000..07f3eee --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetSuggestedTimesheets.18.mcp.json @@ -0,0 +1,31 @@ +[ + { + "billableId": "B", + "category": "Web development", + "client": "Northwind Traders", + "clientId": "NWIND", + "date": "2026-03-16T00:00:00", + "empId": "BOB", + "empName": "Bob Northwind", + "endTime": "2026-03-16T14:00:00", + "hasNotes": true, + "inputSource": null, + "invoiceId": null, + "invoiceType": null, + "isBillable": true, + "isLeave": false, + "isLocked": false, + "isSuggested": true, + "iteration": "Order history", + "iterationId": 3403, + "less": 0, + "location": "SSW", + "locationId": "SSW", + "notes": "Order history", + "project": "Northwind Traders", + "projectId": "1I776Q", + "startTime": "2026-03-16T13:00:00", + "timeId": 4243, + "totalTime": 1 + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetTimesheets.16.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetTimesheets.16.cli.json new file mode 100644 index 0000000..5d51050 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetTimesheets.16.cli.json @@ -0,0 +1,64 @@ +{ + "days": [ + { + "date": "2026-03-16", + "dayOfWeek": "Monday", + "timesheets": [ + { + "billableId": "B", + "category": "Web development", + "client": "Northwind Traders", + "clientId": "NWIND", + "date": "2026-03-16T00:00:00", + "empId": "BOB", + "empName": "Bob Northwind", + "endTime": "2026-03-16T17:00:00", + "hasNotes": true, + "isBillable": true, + "isLeave": false, + "isLocked": false, + "isSuggested": false, + "iteration": "Checkout API", + "less": 0.5, + "location": "Home", + "locationId": "Home", + "notes": "Product search", + "project": "Northwind Traders", + "projectId": "1I776Q", + "startTime": "2026-03-16T09:00:00", + "timeId": 4242, + "totalTime": 7.5 + }, + { + "billableId": "B", + "category": "Web development", + "client": "Northwind Traders", + "clientId": "NWIND", + "date": "2026-03-16T00:00:00", + "empId": "BOB", + "empName": "Bob Northwind", + "endTime": "2026-03-16T14:00:00", + "hasNotes": true, + "isBillable": true, + "isLeave": false, + "isLocked": false, + "isSuggested": true, + "iteration": "Order history", + "iterationId": 3403, + "less": 0, + "location": "SSW", + "locationId": "SSW", + "notes": "Order history", + "project": "Northwind Traders", + "projectId": "1I776Q", + "startTime": "2026-03-16T13:00:00", + "timeId": 4243, + "totalTime": 1 + } + ], + "totalHours": 7.5 + } + ], + "from": "2026-03-16", + "to": "2026-03-16" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetTimesheets.16.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetTimesheets.16.mcp.json new file mode 100644 index 0000000..19520db --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetTimesheets.16.mcp.json @@ -0,0 +1,40 @@ +[ + { + "billableId": "B", + "client": "Northwind Traders", + "clientId": "NWIND", + "date": "2026-03-16", + "empId": "BOB", + "empName": "Bob Northwind", + "endTime": "2026-03-16T17:00:00", + "invoiceId": null, + "isLocked": false, + "isSuggested": false, + "location": "Home", + "notes": "Product search", + "project": "Northwind Traders", + "projectId": "1I776Q", + "startTime": "2026-03-16T09:00:00", + "timeId": 4242, + "totalTime": 7.5 + }, + { + "billableId": "B", + "client": "Northwind Traders", + "clientId": "NWIND", + "date": "2026-03-16", + "empId": "BOB", + "empName": "Bob Northwind", + "endTime": "2026-03-16T14:00:00", + "invoiceId": null, + "isLocked": false, + "isSuggested": true, + "location": "SSW", + "notes": "Order history", + "project": "Northwind Traders", + "projectId": "1I776Q", + "startTime": "2026-03-16T13:00:00", + "timeId": 4243, + "totalTime": 1 + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetTimesheets.17.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetTimesheets.17.cli.json new file mode 100644 index 0000000..a83b589 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetTimesheets.17.cli.json @@ -0,0 +1,64 @@ +{ + "days": [ + { + "date": "2026-03-21", + "dayOfWeek": "Saturday", + "timesheets": [ + { + "billableId": "B", + "category": "Web development", + "client": "Northwind Traders", + "clientId": "NWIND", + "date": "2026-03-21T00:00:00", + "empId": "BOB", + "empName": "Bob Northwind", + "endTime": "2026-03-21T17:00:00", + "hasNotes": true, + "isBillable": true, + "isLeave": false, + "isLocked": false, + "isSuggested": false, + "iteration": "Checkout API", + "less": 0.5, + "location": "Home", + "locationId": "Home", + "notes": "Product search", + "project": "Northwind Traders", + "projectId": "1I776Q", + "startTime": "2026-03-21T09:00:00", + "timeId": 4242, + "totalTime": 7.5 + }, + { + "billableId": "B", + "category": "Web development", + "client": "Northwind Traders", + "clientId": "NWIND", + "date": "2026-03-21T00:00:00", + "empId": "BOB", + "empName": "Bob Northwind", + "endTime": "2026-03-21T14:00:00", + "hasNotes": true, + "isBillable": true, + "isLeave": false, + "isLocked": false, + "isSuggested": true, + "iteration": "Order history", + "iterationId": 3403, + "less": 0, + "location": "SSW", + "locationId": "SSW", + "notes": "Order history", + "project": "Northwind Traders", + "projectId": "1I776Q", + "startTime": "2026-03-21T13:00:00", + "timeId": 4243, + "totalTime": 1 + } + ], + "totalHours": 7.5 + } + ], + "from": "2026-03-21", + "to": "2026-03-21" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetTimesheets.17.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetTimesheets.17.mcp.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetTimesheets.17.mcp.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.weekendDate.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.weekendDate.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.weekendDate.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpParityTests.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpParityTests.cs index ccdc6d2..b7dcf68 100644 --- a/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpParityTests.cs +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpParityTests.cs @@ -1,4 +1,6 @@ using FluentAssertions; +using SSW.TimePro.Cli.Features.Mcp.Tools; +using SSW.TimePro.Cli.Infrastructure.ApiClient; using SSW.TimePro.Cli.Infrastructure.Cli; using Xunit; @@ -93,6 +95,18 @@ public void EveryToolMapsToARegisteredCliCommand_OrIsAllowlisted() "the no-CLI-mirror allowlist may only shrink"); } + [Fact] + public void TimesheetTools_DoNotCallTheApiClientDirectly_ExceptWhereAllowlisted() + { + var direct = ToolIlScanner.MethodsCalling(typeof(TimesheetMcpTools), typeof(ITimeProApiClient)); + + direct.Should().BeSubsetOf(McpCliParityTable.TimesheetToolsUsingApiDirectly, + "a timesheet tool must orchestrate through the shared services, not the API client"); + + McpCliParityTable.TimesheetToolsUsingApiDirectly.Should().BeSubsetOf(direct, + "the allowlist may only shrink: remove entries that no longer call the API client"); + } + private static bool Resolve(string commandPath) { var node = CommandCatalog.Root; diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs index d1a585f..a905298 100644 --- a/tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs @@ -66,6 +66,19 @@ public static class McpCliParityTable "ListAllSkus", }; + /// + /// Timesheet tools that still reach for ITimeProApiClient themselves instead of going + /// through a shared service. Shrink-only: an entry leaves when its slice lands. + /// + public static IReadOnlySet TimesheetToolsUsingApiDirectly { get; } = new HashSet(StringComparer.Ordinal) + { + // The delete call itself; the suggestion pre-check is already shared. + "DeleteTimesheet", + + // A pass-through of one endpoint on both surfaces, so there is nothing to share yet. + "ListIterations", + }; + public static IReadOnlyList Rows { get; } = [ // ───────── Mirrored pairs over a shared service ───────── @@ -139,10 +152,8 @@ public static class McpCliParityTable { CliArgs = ["ts", "check", "--week", "0", "--json"], InvokeTool = (h, ct) => h.Timesheets.CheckWeek(0, ct: ct), - ExpectParity = false, - PermittedDifferences = ["$.tenant", "$.leaveType", "$.days[0].leaveType", "$.days[1].leaveType", - "$.days[2].leaveType", "$.days[3].leaveType", "$.days[4].leaveType"], - Note = "WeekCoverageService is shared; the envelopes still differ in null handling.", + ExpectParity = true, + Note = "WeekCoverageService and WeekCheckResult are both shared.", TokenizeCurrentWeek = true }, @@ -295,8 +306,54 @@ public static class McpCliParityTable // ───────── Not unified yet: declared so a later slice flips the flag ───────── - new("GetTimesheets", "ts get") { Note = "MCP reshapes the row; CLI returns the API shape." }, - new("GetSuggestedTimesheets", "ts suggest") { Note = "Separate projections." }, + new("GetTimesheets", "ts get") + { + CliArgs = ["ts", "get", "--from", Date, "--to", Date, "--json"], + InvokeTool = (h, ct) => h.Timesheets.GetTimesheets(Date, ct: ct), + ExpectParity = false, + PermittedDifferences = ["$"], + Note = "TimesheetLookup.ForRangeAsync is shared; the documents are different kinds — the " + + "CLI answers a from/to envelope of days, MCP a flat array of reshaped rows.", + ExpectedRequests = [new("GET", "/api/Timesheets/GetTimesheetListViewModel")] + }, + + new("GetTimesheets", "ts get") + { + CliArgs = ["ts", "get", "--from", NorthwindApi.WeekendDate, "--to", NorthwindApi.WeekendDate, "--json"], + InvokeTool = (h, ct) => h.Timesheets.GetTimesheets(NorthwindApi.WeekendDate, ct: ct), + ExpectParity = false, + PermittedDifferences = ["$"], + Note = "The weekend policy, locked by the two goldens: the CLI reads the Saturday, MCP " + + "skips it and returns an empty array without calling the API." + }, + + new("GetSuggestedTimesheets", "ts suggest") + { + CliArgs = ["ts", "suggest", Date, "--json"], + InvokeTool = (h, ct) => h.Timesheets.GetSuggestedTimesheets(Date, ct), + ExpectParity = false, + + // Both documents are one-element arrays, so the diff lands per key rather than at the + // root: the CLI element is the {date, suggested} group, the MCP element is the entry. + PermittedDifferences = + [ + "$[0].date", "$[0].suggested", + "$[0].timeId", "$[0].empId", "$[0].empName", "$[0].client", "$[0].clientId", + "$[0].project", "$[0].projectId", "$[0].iteration", "$[0].iterationId", + "$[0].startTime", "$[0].endTime", "$[0].totalTime", "$[0].less", + "$[0].location", "$[0].locationId", "$[0].category", "$[0].billableId", + "$[0].isBillable", "$[0].isSuggested", "$[0].isLeave", "$[0].isLocked", + "$[0].hasNotes", "$[0].notes", "$[0].inputSource", + "$[0].invoiceId", "$[0].invoiceType" + ], + Note = "The refresh-then-read is shared and happens once; the CLI groups the day's " + + "suggestions under a date, MCP returns the entries alone.", + ExpectedRequests = + [ + new("GET", "/api/Timesheets/RefreshSuggestedTimesheets"), + new("GET", "/api/Timesheets/GetTimesheetListViewModel") + ] + }, new("GetLocationAndMapping", "location info") { Note = "MCP merges location defaults and repo mapping." }, new("GetLeaveEntries", "leave list") { Note = "MCP returns the items array, CLI the envelope." }, new("GetLeaveBalance", "leave balance") { Note = "Separate projections." }, diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs index d13afa1..b65779d 100644 --- a/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs @@ -33,6 +33,13 @@ private static IReadOnlyList BuildPopulated() => EmptyBody = "[]" }, + // A Saturday: the tool's weekend policy answers with an empty array and never calls the API. + new("GetTimesheets", "weekendDate", + (h, ct) => h.Timesheets.GetTimesheets(NorthwindApi.WeekendDate, ct: ct)) + { + HasApiErrorCase = false + }, + // The note and start time match the day's existing row so the empty-body read-back // actually finds the entry it claims to have created. new("CreateTimesheet", "populated", (h, ct) => h.Timesheets.CreateTimesheet( diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/NorthwindApi.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/NorthwindApi.cs index cb134f3..6281be5 100644 --- a/tests/SSW.TimePro.Cli.Integration/Mcp/NorthwindApi.cs +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/NorthwindApi.cs @@ -30,6 +30,9 @@ public static class NorthwindApi public const string AnyDate = "2026-03-16"; + /// A Saturday: the CLI reads it, the MCP GetTimesheets tool skips it. + public const string WeekendDate = "2026-03-21"; + /// Baseline stubs sit below per-case overrides so a case can replace one route. public const int BaselinePriority = 10; public const int OverridePriority = 1; @@ -198,7 +201,17 @@ private static void StubLookups(WireMockServer server) // ───────────────────────── Timesheets ───────────────────────── - public static List Day(bool isSuggested = false) => + private static DateOnly RequestedDate(WireMock.IRequestMessage request) => + request.Query?.TryGetValue("date", out var values) == true + && DateOnly.TryParse(values.FirstOrDefault(), out var date) + ? date + : DateOnly.Parse(AnyDate); + + /// + /// The day's entries, dated to so a fixture read for a Saturday does + /// not answer with Monday rows. + /// + public static List Day(DateOnly date, bool isSuggested = false) => [ new() { @@ -215,9 +228,9 @@ public static List Day(bool isSuggested = false) => Location = "Home", LocationId = "Home", Notes = "Product search", - Date = "2026-03-16T00:00:00", - StartTime = "2026-03-16T09:00:00", - EndTime = "2026-03-16T17:00:00", + Date = $"{date:yyyy-MM-dd}T00:00:00", + StartTime = $"{date:yyyy-MM-dd}T09:00:00", + EndTime = $"{date:yyyy-MM-dd}T17:00:00", BillableId = "B", IsBillable = true, Less = 0.5m, @@ -243,9 +256,9 @@ public static List Day(bool isSuggested = false) => Location = "SSW", LocationId = "SSW", Notes = "Order history", - Date = "2026-03-16T00:00:00", - StartTime = "2026-03-16T13:00:00", - EndTime = "2026-03-16T14:00:00", + Date = $"{date:yyyy-MM-dd}T00:00:00", + StartTime = $"{date:yyyy-MM-dd}T13:00:00", + EndTime = $"{date:yyyy-MM-dd}T14:00:00", BillableId = "B", IsBillable = true, Less = 0m, @@ -259,7 +272,12 @@ public static List Day(bool isSuggested = false) => private static void StubTimesheets(WireMockServer server) { - Json(server, "/api/Timesheets/GetTimesheetListViewModel", "GET", Day()); + server.Given(Request.Create().WithPath("/api/Timesheets/GetTimesheetListViewModel").UsingGet()) + .AtPriority(BaselinePriority) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(request => JsonSerializer.Serialize(Day(RequestedDate(request)), Body))); Json(server, "/api/timesheetSummary/GetTableSummarydata", "POST", new List { diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/ToolIlScanner.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/ToolIlScanner.cs new file mode 100644 index 0000000..94acce1 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/ToolIlScanner.cs @@ -0,0 +1,138 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Text.RegularExpressions; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// Reads the IL of an MCP tool class to find which tools still call a given type's members +/// themselves. Constructor inspection cannot answer this: the shared services are static helpers +/// that take the API client, so a migrated class still holds the dependency to pass it on. +/// +public static class ToolIlScanner +{ + private static readonly Dictionary Opcodes = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(f => (OpCode)f.GetValue(null)!) + .ToDictionary(o => o.Value); + + private static readonly Regex OwningMethod = new(@"<([^>]+)>", RegexOptions.Compiled); + + private const BindingFlags AnyMember = + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + + /// + /// The tool methods on whose own code (including its async state + /// machine and lambdas) calls a member declared on . + /// + public static IReadOnlySet MethodsCalling(Type toolType, Type dependency) + { + var toolMethods = McpToolInventory.Methods(toolType).Select(m => m.Name).ToHashSet(StringComparer.Ordinal); + var callers = new HashSet(StringComparer.Ordinal); + + foreach (var type in Types(toolType)) + { + foreach (var method in type.GetMethods(AnyMember).Cast().Concat(type.GetConstructors(AnyMember))) + { + if (!CallsInto(method, dependency)) + continue; + + // Fail closed: an unattributable caller would otherwise let a private helper reach + // the API client with no tool name for the allowlist to catch. + callers.Add(Owner(method, toolMethods) + ?? throw new InvalidOperationException( + $"{method.DeclaringType!.Name}.{method.Name} calls {dependency.Name} but " + + "belongs to no MCP tool method. Move the call into a shared service, or " + + "inline it into the tool so the allowlist can name it.")); + } + } + + return callers; + } + + private static IEnumerable Types(Type toolType) => + new[] { toolType }.Concat(toolType.GetNestedTypes(AnyMember)); + + /// Attributes compiler-generated state machines and lambdas back to their tool method. + private static string? Owner(MethodBase method, IReadOnlySet toolMethods) + { + if (toolMethods.Contains(method.Name)) + return method.Name; + + foreach (var candidate in OwningMethod.Matches(method.DeclaringType!.Name + " " + method.Name) + .Select(m => m.Groups[1].Value)) + if (toolMethods.Contains(candidate)) + return candidate; + + return null; + } + + private static bool CallsInto(MethodBase method, Type dependency) + { + var il = SafeBody(method); + if (il is null) + return false; + + var typeArgs = method.DeclaringType?.IsGenericType == true ? method.DeclaringType.GetGenericArguments() : null; + var methodArgs = method.IsGenericMethodDefinition ? method.GetGenericArguments() : null; + + for (var i = 0; i < il.Length;) + { + short code = il[i]; + i++; + if (code == 0xFE) + { + code = (short)(0xFE00 | il[i]); + i++; + } + + if (!Opcodes.TryGetValue(code, out var opcode)) + throw new InvalidOperationException( + $"Unknown IL opcode 0x{code:X} in {method.DeclaringType?.Name}.{method.Name}; " + + "the scanner would silently stop finding calls."); + + if (opcode.OperandType is OperandType.InlineMethod + && Resolve(method.Module, BitConverter.ToInt32(il, i), typeArgs, methodArgs) is { } member + && member.DeclaringType == dependency) + return true; + + i += OperandSize(opcode, il, i); + } + + return false; + } + + private static byte[]? SafeBody(MethodBase method) + { + try + { + return method.GetMethodBody()?.GetILAsByteArray(); + } + catch (Exception) + { + return null; + } + } + + private static MemberInfo? Resolve(Module module, int token, Type[]? typeArgs, Type[]? methodArgs) + { + try + { + return module.ResolveMember(token, typeArgs, methodArgs); + } + catch (Exception) + { + return null; + } + } + + private static int OperandSize(OpCode opcode, byte[] il, int operandStart) => opcode.OperandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch => 4 + (4 * BitConverter.ToInt32(il, operandStart)), + _ => 4 + }; +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/ToolIlScannerTests.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/ToolIlScannerTests.cs new file mode 100644 index 0000000..a5296cb --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/ToolIlScannerTests.cs @@ -0,0 +1,54 @@ +using System.ComponentModel; +using FluentAssertions; +using ModelContextProtocol.Server; +using SSW.TimePro.Cli.Infrastructure.ApiClient; +using Xunit; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +public class ToolIlScannerTests +{ + [Fact] + public void FindsTheToolBehindADirectCall() + { + var direct = ToolIlScanner.MethodsCalling(typeof(ToolsFixture), typeof(ITimeProApiClient)); + + direct.Should().BeEquivalentTo([nameof(ToolsFixture.CallsTheApiItself)]); + } + + [Fact] + public void RefusesToAttributeACallMadeFromAPrivateHelper() + { + var scan = () => ToolIlScanner.MethodsCalling(typeof(HelperFixture), typeof(ITimeProApiClient)); + + scan.Should().Throw().WithMessage("*Helper*ITimeProApiClient*"); + } + + [McpServerToolType] + private class ToolsFixture + { + private readonly ITimeProApiClient _api = null!; + + [McpServerTool] + [Description("Fixture: reads through the API client.")] + public async Task CallsTheApiItself(CancellationToken ct) => + (await _api.GetIterationsAsync("1I776Q", ct)).Count.ToString(); + + [McpServerTool] + [Description("Fixture: touches nothing.")] + public string CallsNothing() => "ok"; + } + + [McpServerToolType] + private class HelperFixture + { + private readonly ITimeProApiClient _api = null!; + + [McpServerTool] + [Description("Fixture: delegates to a private helper that reads.")] + public Task DelegatesToAHelper(CancellationToken ct) => Helper(ct); + + private async Task Helper(CancellationToken ct) => + (await _api.GetIterationsAsync("1I776Q", ct)).Count.ToString(); + } +} diff --git a/tests/SSW.TimePro.Cli.Tests/Features/Timesheets/TimesheetLookupReadTests.cs b/tests/SSW.TimePro.Cli.Tests/Features/Timesheets/TimesheetLookupReadTests.cs new file mode 100644 index 0000000..73513d3 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Tests/Features/Timesheets/TimesheetLookupReadTests.cs @@ -0,0 +1,106 @@ +using FluentAssertions; +using NSubstitute; +using SSW.TimePro.Cli.Features.Timesheets; +using SSW.TimePro.Cli.Infrastructure.ApiClient; +using SSW.TimePro.Cli.Shared.Models; +using Xunit; + +namespace SSW.TimePro.Cli.Tests.Features.Timesheets; + +public class TimesheetLookupReadTests +{ + private const string Emp = "BOB"; + + // Monday 16 March 2026 to Sunday 22 March 2026. + private static readonly DateOnly Monday = new(2026, 3, 16); + private static readonly DateOnly Saturday = new(2026, 3, 21); + private static readonly DateOnly Sunday = new(2026, 3, 22); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task ForRange_WithWeekendsIncluded_ReadsEveryDayInOrder() + { + var api = Api(); + + var days = await TimesheetLookup.ForRangeAsync(api, Emp, Monday, Sunday, WeekendPolicy.Include, Ct); + + days.Select(d => d.Date).Should().Equal(Enumerable.Range(0, 7).Select(Monday.AddDays)); + await api.Received(7).GetTimesheetsAsync(Emp, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ForRange_WithWeekendsSkipped_OmitsSaturdayAndSunday() + { + var api = Api(); + + var days = await TimesheetLookup.ForRangeAsync(api, Emp, Monday, Sunday, WeekendPolicy.Skip, Ct); + + days.Select(d => d.Date).Should().Equal(Enumerable.Range(0, 5).Select(Monday.AddDays)); + await api.DidNotReceive().GetTimesheetsAsync(Emp, Saturday, Arg.Any()); + await api.DidNotReceive().GetTimesheetsAsync(Emp, Sunday, Arg.Any()); + } + + [Fact] + public async Task ForRange_WithWeekendsSkipped_AndAWeekendOnlyRange_ReadsNothing() + { + var api = Api(); + + var days = await TimesheetLookup.ForRangeAsync(api, Emp, Saturday, Saturday, WeekendPolicy.Skip, Ct); + + days.Should().BeEmpty(); + await api.DidNotReceive().GetTimesheetsAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ForRange_WithWeekendsIncluded_AndAWeekendOnlyRange_ReadsTheDay() + { + var api = Api(); + + var days = await TimesheetLookup.ForRangeAsync(api, Emp, Saturday, Saturday, WeekendPolicy.Include, Ct); + + days.Select(d => d.Date).Should().Equal([Saturday]); + await api.Received(1).GetTimesheetsAsync(Emp, Saturday, Arg.Any()); + } + + [Fact] + public async Task RefreshAndReadSuggested_RefreshesOnceThenReturnsOnlySuggestions() + { + var api = Api(); + api.GetTimesheetsAsync(Emp, Monday, Arg.Any()).Returns( + [ + new TimesheetItem { TimeId = 1, IsSuggested = false }, + new TimesheetItem { TimeId = 2, IsSuggested = true } + ]); + + var day = await TimesheetLookup.RefreshAndReadSuggestedAsync(api, Emp, Monday, Ct); + + day.Date.Should().Be(Monday); + day.Entries.Select(t => t.TimeId).Should().Equal([2]); + await api.Received(1).RefreshSuggestedTimesheetsAsync(Emp, Monday, Arg.Any()); + await api.Received(1).GetTimesheetsAsync(Emp, Monday, Arg.Any()); + } + + [Fact] + public async Task RefreshAndReadSuggested_RefreshesBeforeReading() + { + var api = Api(); + + await TimesheetLookup.RefreshAndReadSuggestedAsync(api, Emp, Monday, Ct); + + Received.InOrder(() => + { + api.RefreshSuggestedTimesheetsAsync(Emp, Monday, Arg.Any()); + api.GetTimesheetsAsync(Emp, Monday, Arg.Any()); + }); + } + + private static ITimeProApiClient Api() + { + var api = Substitute.For(); + api.GetTimesheetsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + return api; + } +}