From 47616f327f38df0b34634afbfab5144f1ebd0976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jernej=20Kavka=20=28JK=29=20=5BSSW=20=C2=B7=20Microsoft=20?= =?UTF-8?q?MVP=5D?= Date: Sun, 13 Sep 2026 00:35:42 +1000 Subject: [PATCH 1/2] test(mcp): regression harness for tools/list, tool results, CLI parity and staging stdio Locks the observable MCP contract before the CLI/MCP unification slices touch it. - Golden tools/list taken from the real stdio server, accounting off (18) and on (47) - Per-tool result goldens for all 47 tools plus generated empty and API-error cases - tools/call envelopes over stdio for six tools, proven identical to the direct calls - CLI/MCP parity runner with per-case permitted differences and write-traffic assertions - Reflection test mapping every tool to a registered CLI command, with a shrink-only allowlist - scripts/e2e/test-mcp-smoke.sh: staging stdio gate with non-production check and cleanup ConfigPaths honours TIMEPRO_CLI_CONFIG_DIR so a child MCP host can run against an isolated config instead of the developer's own tenants and feature flags. Closes #63 Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + AGENTS.md | 32 + scripts/e2e/mcp_smoke.py | 322 ++++ scripts/e2e/test-mcp-smoke.sh | 26 + .../Infrastructure/Config/ConfigPaths.cs | 21 +- .../Goldens/Mcp/Calls/delete_timesheet.json | 8 + .../Goldens/Mcp/Calls/get_client_rate.json | 8 + .../Goldens/Mcp/Calls/get_leave_entries.json | 8 + .../Goldens/Mcp/Calls/get_timesheets.json | 8 + .../Goldens/Mcp/Calls/list_iterations.json | 8 + .../Goldens/Mcp/Calls/update_timesheet.json | 8 + .../Mcp/Discovery/tools-list.accounting.json | 1501 +++++++++++++++++ .../Mcp/Discovery/tools-list.default.json | 717 ++++++++ .../AcceptSuggestedTimesheet.1.cli.json | 30 + .../AcceptSuggestedTimesheet.1.mcp.json | 36 + .../Goldens/Mcp/Parity/CheckWeek.4.cli.json | 96 ++ .../Goldens/Mcp/Parity/CheckWeek.4.mcp.json | 96 ++ .../Goldens/Mcp/Parity/CreateLeave.5.cli.json | 17 + .../Goldens/Mcp/Parity/CreateLeave.5.mcp.json | 18 + .../Mcp/Parity/DeleteTimesheet.2.cli.json | 4 + .../Mcp/Parity/DeleteTimesheet.2.mcp.json | 4 + .../Mcp/Parity/DeleteTimesheet.3.cli.json | 7 + .../Mcp/Parity/DeleteTimesheet.3.mcp.json | 3 + .../Parity/GetLeaveBalanceStatus.8.cli.json | 6 + .../Parity/GetLeaveBalanceStatus.8.mcp.json | 6 + .../Mcp/Parity/ImportLeaveBalances.7.cli.json | 11 + .../Mcp/Parity/ImportLeaveBalances.7.mcp.json | 12 + .../Goldens/Mcp/Parity/UpdateLeave.6.cli.json | 22 + .../Goldens/Mcp/Parity/UpdateLeave.6.mcp.json | 23 + .../Mcp/Parity/UpdateTimesheet.0.cli.json | 29 + .../Mcp/Parity/UpdateTimesheet.0.mcp.json | 36 + .../AcceptSuggestedTimesheet.apiError.json | 6 + .../AcceptSuggestedTimesheet.populated.json | 36 + .../Goldens/Mcp/Tools/CheckWeek.apiError.json | 6 + .../Mcp/Tools/CheckWeek.populated.json | 96 ++ .../Mcp/Tools/CreateLeave.apiError.json | 6 + .../Goldens/Mcp/Tools/CreateLeave.dryRun.json | 18 + .../Mcp/Tools/CreateTimesheet.apiError.json | 6 + .../Mcp/Tools/CreateTimesheet.populated.json | 1 + .../Mcp/Tools/DeleteTimesheet.apiError.json | 6 + .../Mcp/Tools/DeleteTimesheet.populated.json | 4 + .../Tools/GetClientOutstanding.apiError.json | 6 + .../Mcp/Tools/GetClientOutstanding.empty.json | 1 + .../Tools/GetClientOutstanding.populated.json | 17 + .../Mcp/Tools/GetClientRate.apiError.json | 6 + .../Mcp/Tools/GetClientRate.empty.json | 1 + .../Mcp/Tools/GetClientRate.populated.json | 11 + ...etClientsWithOutstandingTime.apiError.json | 6 + .../GetClientsWithOutstandingTime.empty.json | 1 + ...tClientsWithOutstandingTime.populated.json | 15 + .../Mcp/Tools/GetCrmBookings.apiError.json | 6 + .../Mcp/Tools/GetCrmBookings.empty.json | 1 + .../Mcp/Tools/GetCrmBookings.populated.json | 14 + .../Mcp/Tools/GetCurrentUser.apiError.json | 6 + .../Mcp/Tools/GetCurrentUser.empty.json | 1 + .../Mcp/Tools/GetCurrentUser.populated.json | 7 + .../Mcp/Tools/GetInvoice.apiError.json | 6 + .../Goldens/Mcp/Tools/GetInvoice.empty.json | 1 + .../Mcp/Tools/GetInvoice.populated.json | 30 + .../Mcp/Tools/GetInvoiceLines.apiError.json | 6 + .../Mcp/Tools/GetInvoiceLines.empty.json | 1 + .../Mcp/Tools/GetInvoiceLines.populated.json | 17 + .../Tools/GetInvoiceReceipts.apiError.json | 6 + .../Mcp/Tools/GetInvoiceReceipts.empty.json | 1 + .../Tools/GetInvoiceReceipts.populated.json | 13 + .../Tools/GetInvoiceTimesheets.apiError.json | 6 + .../Mcp/Tools/GetInvoiceTimesheets.empty.json | 1 + .../Tools/GetInvoiceTimesheets.populated.json | 25 + .../Tools/GetInvoicesByClient.apiError.json | 6 + .../Mcp/Tools/GetInvoicesByClient.empty.json | 1 + .../Tools/GetInvoicesByClient.populated.json | 32 + .../Mcp/Tools/GetLeaveBalance.apiError.json | 6 + .../Mcp/Tools/GetLeaveBalance.empty.json | 3 + .../Mcp/Tools/GetLeaveBalance.populated.json | 5 + .../Tools/GetLeaveBalanceStatus.apiError.json | 6 + .../Tools/GetLeaveBalanceStatus.empty.json | 3 + .../GetLeaveBalanceStatus.populated.json | 6 + .../Mcp/Tools/GetLeaveEntries.apiError.json | 6 + .../Mcp/Tools/GetLeaveEntries.empty.json | 1 + .../Mcp/Tools/GetLeaveEntries.populated.json | 31 + .../GetLocationAndMapping.populated.json | 10 + .../Mcp/Tools/GetPrepaidStatus.apiError.json | 6 + .../Mcp/Tools/GetPrepaidStatus.empty.json | 3 + .../Mcp/Tools/GetPrepaidStatus.populated.json | 33 + .../Tools/GetPrepaidStatusPdf.apiError.json | 6 + .../Tools/GetPrepaidStatusPdf.populated.json | 7 + .../Mcp/Tools/GetProduct.apiError.json | 6 + .../Goldens/Mcp/Tools/GetProduct.empty.json | 1 + .../Mcp/Tools/GetProduct.populated.json | 25 + ...GetProductDiscountsForClient.apiError.json | 6 + .../GetProductDiscountsForClient.empty.json | 1 + ...etProductDiscountsForClient.populated.json | 11 + .../Tools/GetProjectsForClient.apiError.json | 6 + .../Mcp/Tools/GetProjectsForClient.empty.json | 1 + .../Tools/GetProjectsForClient.populated.json | 16 + .../Tools/GetProjectsSummary.apiError.json | 6 + .../Mcp/Tools/GetProjectsSummary.empty.json | 1 + .../Tools/GetProjectsSummary.populated.json | 11 + .../Mcp/Tools/GetReceiptDetail.apiError.json | 6 + .../Mcp/Tools/GetReceiptDetail.empty.json | 1 + .../Mcp/Tools/GetReceiptDetail.populated.json | 38 + .../Tools/GetRecurringInvoice.apiError.json | 6 + .../Mcp/Tools/GetRecurringInvoice.empty.json | 1 + .../Tools/GetRecurringInvoice.populated.json | 36 + .../GetSuggestedTimesheets.apiError.json | 6 + .../Tools/GetSuggestedTimesheets.empty.json | 1 + .../GetSuggestedTimesheets.populated.json | 31 + .../Mcp/Tools/GetTimesheets.apiError.json | 6 + .../Mcp/Tools/GetTimesheets.empty.json | 1 + .../Mcp/Tools/GetTimesheets.populated.json | 40 + ...tUnbilledTimesheetsForClient.apiError.json | 6 + .../GetUnbilledTimesheetsForClient.empty.json | 1 + ...UnbilledTimesheetsForClient.populated.json | 25 + .../GetUnpaidInvoicesByClient.apiError.json | 6 + .../GetUnpaidInvoicesByClient.empty.json | 1 + .../GetUnpaidInvoicesByClient.populated.json | 32 + .../Tools/ImportLeaveBalances.apiError.json | 4 + .../Tools/ImportLeaveBalances.populated.json | 12 + .../Mcp/Tools/ListAllSkus.apiError.json | 6 + .../Goldens/Mcp/Tools/ListAllSkus.empty.json | 1 + .../Mcp/Tools/ListAllSkus.populated.json | 14 + .../Mcp/Tools/ListBillableTypes.apiError.json | 6 + .../Mcp/Tools/ListBillableTypes.empty.json | 1 + .../Tools/ListBillableTypes.populated.json | 14 + .../Mcp/Tools/ListCategories.apiError.json | 6 + .../Mcp/Tools/ListCategories.empty.json | 1 + .../Mcp/Tools/ListCategories.populated.json | 12 + .../Mcp/Tools/ListClientRates.apiError.json | 6 + .../Mcp/Tools/ListClientRates.empty.json | 4 + .../Mcp/Tools/ListClientRates.populated.json | 18 + .../Mcp/Tools/ListCreditNotes.apiError.json | 6 + .../Mcp/Tools/ListCreditNotes.empty.json | 1 + .../Mcp/Tools/ListCreditNotes.populated.json | 15 + .../Mcp/Tools/ListInvoices.apiError.json | 6 + .../Goldens/Mcp/Tools/ListInvoices.empty.json | 4 + .../Mcp/Tools/ListInvoices.populated.json | 15 + .../Mcp/Tools/ListIterations.apiError.json | 6 + .../Mcp/Tools/ListIterations.empty.json | 1 + .../Mcp/Tools/ListIterations.populated.json | 12 + .../Mcp/Tools/ListLocations.apiError.json | 6 + .../Mcp/Tools/ListLocations.empty.json | 1 + .../Mcp/Tools/ListLocations.populated.json | 10 + .../Mcp/Tools/ListPaidReceipts.apiError.json | 6 + .../Mcp/Tools/ListPaidReceipts.empty.json | 4 + .../Mcp/Tools/ListPaidReceipts.populated.json | 26 + .../Mcp/Tools/ListProducts.apiError.json | 6 + .../Goldens/Mcp/Tools/ListProducts.empty.json | 1 + .../Mcp/Tools/ListProducts.populated.json | 28 + .../Tools/ListRecurringInvoices.apiError.json | 6 + .../Tools/ListRecurringInvoices.empty.json | 4 + .../ListRecurringInvoices.populated.json | 17 + .../Mcp/Tools/QueryTimesheets.apiError.json | 6 + .../Mcp/Tools/QueryTimesheets.empty.json | 1 + .../Mcp/Tools/QueryTimesheets.populated.json | 19 + .../Mcp/Tools/SearchClients.apiError.json | 6 + .../Mcp/Tools/SearchClients.empty.json | 1 + .../Mcp/Tools/SearchClients.populated.json | 6 + .../Mcp/Tools/UpdateLeave.apiError.json | 6 + .../Goldens/Mcp/Tools/UpdateLeave.dryRun.json | 23 + .../Mcp/Tools/UpdateTimesheet.apiError.json | 6 + .../Mcp/Tools/UpdateTimesheet.populated.json | 36 + .../Mcp/CliBinary.cs | 49 + .../Mcp/CliMcpParityTests.cs | 163 ++ .../Mcp/CliRunner.cs | 71 + .../SSW.TimePro.Cli.Integration/Mcp/Golden.cs | 151 ++ .../Mcp/IsolatedConfigDirectory.cs | 62 + .../Mcp/JsonDiff.cs | 67 + .../Mcp/McpCliParityTable.cs | 241 +++ .../Mcp/McpStdioClient.cs | 223 +++ .../Mcp/McpStdioDiscoveryTests.cs | 90 + .../Mcp/McpStdioToolCallTests.cs | 91 + .../Mcp/McpToolCase.cs | 39 + .../Mcp/McpToolCatalog.cs | 432 +++++ .../Mcp/McpToolHost.cs | 29 + .../Mcp/McpToolInventory.cs | 71 + .../Mcp/McpToolResultGoldenTests.cs | 66 + .../Mcp/NorthwindApi.cs | 779 +++++++++ .../Mcp/TestConfigService.cs | 36 + .../Mcp/WeekTokens.cs | 22 + .../Infrastructure/ConfigPathsTests.cs | 51 + 180 files changed, 7153 insertions(+), 3 deletions(-) create mode 100755 scripts/e2e/mcp_smoke.py create mode 100755 scripts/e2e/test-mcp-smoke.sh create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/delete_timesheet.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/get_client_rate.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/get_leave_entries.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/get_timesheets.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/list_iterations.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/update_timesheet.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Discovery/tools-list.accounting.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Discovery/tools-list.default.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/AcceptSuggestedTimesheet.1.cli.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/AcceptSuggestedTimesheet.1.mcp.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CheckWeek.4.cli.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CheckWeek.4.mcp.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateLeave.5.cli.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateLeave.5.mcp.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.2.cli.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.2.mcp.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.3.cli.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.3.mcp.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetLeaveBalanceStatus.8.cli.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetLeaveBalanceStatus.8.mcp.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/ImportLeaveBalances.7.cli.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/ImportLeaveBalances.7.mcp.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateLeave.6.cli.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateLeave.6.mcp.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateTimesheet.0.cli.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateTimesheet.0.mcp.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/AcceptSuggestedTimesheet.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/AcceptSuggestedTimesheet.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CheckWeek.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CheckWeek.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateLeave.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateLeave.dryRun.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/DeleteTimesheet.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/DeleteTimesheet.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientOutstanding.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientOutstanding.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientOutstanding.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientRate.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientRate.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientRate.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientsWithOutstandingTime.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientsWithOutstandingTime.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientsWithOutstandingTime.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCrmBookings.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCrmBookings.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCrmBookings.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCurrentUser.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCurrentUser.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCurrentUser.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoice.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoice.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoice.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceLines.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceLines.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceLines.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceReceipts.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceReceipts.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceReceipts.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceTimesheets.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceTimesheets.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceTimesheets.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoicesByClient.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoicesByClient.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoicesByClient.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalance.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalance.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalance.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalanceStatus.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalanceStatus.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalanceStatus.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveEntries.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveEntries.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveEntries.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLocationAndMapping.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatus.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatus.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatus.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatusPdf.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatusPdf.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProduct.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProduct.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProduct.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProductDiscountsForClient.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProductDiscountsForClient.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProductDiscountsForClient.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsForClient.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsForClient.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsForClient.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsSummary.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsSummary.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsSummary.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetReceiptDetail.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetReceiptDetail.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetReceiptDetail.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetRecurringInvoice.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetRecurringInvoice.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetRecurringInvoice.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetSuggestedTimesheets.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetSuggestedTimesheets.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetSuggestedTimesheets.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnbilledTimesheetsForClient.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnbilledTimesheetsForClient.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnbilledTimesheetsForClient.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnpaidInvoicesByClient.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnpaidInvoicesByClient.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnpaidInvoicesByClient.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ImportLeaveBalances.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ImportLeaveBalances.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListAllSkus.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListAllSkus.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListAllSkus.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListBillableTypes.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListBillableTypes.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListBillableTypes.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCategories.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCategories.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCategories.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListClientRates.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListClientRates.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListClientRates.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCreditNotes.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCreditNotes.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCreditNotes.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListInvoices.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListInvoices.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListInvoices.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListIterations.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListIterations.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListIterations.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListLocations.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListLocations.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListLocations.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListPaidReceipts.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListPaidReceipts.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListPaidReceipts.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListProducts.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListProducts.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListProducts.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListRecurringInvoices.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListRecurringInvoices.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListRecurringInvoices.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/QueryTimesheets.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/QueryTimesheets.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/QueryTimesheets.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/SearchClients.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/SearchClients.empty.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/SearchClients.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateLeave.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateLeave.dryRun.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateTimesheet.apiError.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateTimesheet.populated.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/CliBinary.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpParityTests.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/CliRunner.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/Golden.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/IsolatedConfigDirectory.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/JsonDiff.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/McpStdioClient.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/McpStdioDiscoveryTests.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/McpStdioToolCallTests.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCase.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/McpToolHost.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/McpToolInventory.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/McpToolResultGoldenTests.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/NorthwindApi.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/TestConfigService.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/WeekTokens.cs create mode 100644 tests/SSW.TimePro.Cli.Tests/Infrastructure/ConfigPathsTests.cs diff --git a/.gitignore b/.gitignore index 927576f..23d9ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ src/**/.agents/ ## Generated reports Reports/ +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 9fb547d..0c80d2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,11 @@ the command. `--verbose` is stripped from argv like `--tenant` and prints to std - Per-tenant: `~/.config/timepro-cli/tenants/{id}.json` - Repo mappings: `~/.config/timepro-cli/repo-mappings.json` - Feature packs: `config.json` stores `features..enabled` and `features..version` so skills and MCP can share one persistent setting. +- `TIMEPRO_CLI_CONFIG_DIR` relocates the whole config root (`~`, relative and absolute paths all + work). It is read once by `ConfigPaths`, so it also moves the tenants directory, repo mappings and + the local command log. It exists so the MCP regression harness can launch a real `tp mcp` child + process against an isolated config instead of the developer's own tenants and feature flags; use + it for any test or script that must not read or write the real config. ### Project Files - `Directory.Build.props` centralizes shared .NET defaults (`net10.0`, implicit usings, nullable) @@ -219,6 +224,29 @@ separate DTOs: property no DTO property binds. Use it on new endpoint tests — it is what catches shape drift before it shows up as a zeroed field. +## MCP Regression Harness + +`tests/SSW.TimePro.Cli.Integration/Mcp/` holds the contract tripwire for the CLI/MCP unification +work, with its snapshots under `Goldens/Mcp/`. Adding a tool means adding to the tables — several +tests fail until you do. + +- `NorthwindApi` is the single fake TimePro instance. Response bodies are serialised from the real + DTOs, never hand-written, so a fixture that stops binding fails instead of producing a golden + full of nulls. Per-case overrides go in at `OverridePriority`. +- `McpToolCatalog` declares one populated case per tool plus the generated `empty` and `apiError` + variants; `Goldens/Mcp/Tools/*.json` is the raw text each tool returned. An API failure escaping + a tool as a protocol error rather than an `isError` payload is part of what is snapshotted. +- `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. +- `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. +- Goldens are read from and written to the source tree. Regenerate deliberately with + `UPDATE_MCP_GOLDENS=1 dotnet test tests/SSW.TimePro.Cli.Integration/` and review the diff. +- The one declared normalisation is `WeekTokens`: `WeekCoverageService` derives its window from the + machine clock and has no clock seam, so the current week's five dates become tokens. + ## Testing ```bash @@ -231,6 +259,10 @@ dotnet test tests/SSW.TimePro.Cli.Integration/ # E2E (requires staging credentials) ./scripts/e2e/run-all.sh +# Staging MCP stdio gate only (run the candidate artifact, never production) +TIMEPRO_MCP_SMOKE_TP="dotnet src/SSW.TimePro.Cli/bin/Release/net10.0/SSW.TimePro.Cli.dll" \ + scripts/e2e/test-mcp-smoke.sh + # NuGet package safety audit (also used by the optional Git pre-push hook) scripts/security/nuget-audit.sh ``` diff --git a/scripts/e2e/mcp_smoke.py b/scripts/e2e/mcp_smoke.py new file mode 100755 index 0000000..2be1993 --- /dev/null +++ b/scripts/e2e/mcp_smoke.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Staging MCP stdio smoke test: discovery, Northwind reads, one write, and cleanup. + +Standard library only. Driven by scripts/e2e/test-mcp-smoke.sh; see that script for the +environment variables it accepts. +""" + +import datetime +import json +import os +import pathlib +import subprocess +import sys +import threading +import uuid + +PROTOCOL_VERSION = "2024-11-05" +READ_TIMEOUT_SECONDS = 90 + +CLIENT_ID = "NWIND" +PROJECT_ID = os.environ.get("TIMEPRO_MCP_SMOKE_PROJECT", "8W52M2") +TENANT = os.environ.get("TIMEPRO_MCP_SMOKE_TENANT", "ssw-staging") +CATEGORY_ID = os.environ.get("TIMEPRO_MCP_SMOKE_CATEGORY", "WEBDEV") + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +DISCOVERY_GOLDEN = ( + REPO_ROOT + / "tests" + / "SSW.TimePro.Cli.Integration" + / "Goldens" + / "Mcp" + / "Discovery" + / "tools-list.default.json" +) + + +class SmokeFailure(Exception): + pass + + +class McpProcess: + """Newline-delimited JSON-RPC over the child's stdio, with stderr drained separately.""" + + def __init__(self, command): + self._process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + self._next_id = 1 + self._stderr = [] + self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True) + self._stderr_thread.start() + + def _drain_stderr(self): + for line in self._process.stderr: + self._stderr.append(line.rstrip()) + + @property + def stderr(self): + return "\n".join(self._stderr) + + def request(self, method, params=None): + request_id = self._next_id + self._next_id += 1 + frame = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + frame["params"] = params + self._write(frame) + return self._read_response(request_id) + + def notify(self, method, params=None): + frame = {"jsonrpc": "2.0", "method": method} + if params is not None: + frame["params"] = params + self._write(frame) + + def _write(self, frame): + self._process.stdin.write(json.dumps(frame) + "\n") + self._process.stdin.flush() + + def _read_response(self, request_id): + deadline = threading.Event() + timer = threading.Timer(READ_TIMEOUT_SECONDS, deadline.set) + timer.start() + try: + while True: + if deadline.is_set(): + raise SmokeFailure(f"timed out waiting for response {request_id}") + + line = self._process.stdout.readline() + if line == "": + raise SmokeFailure( + f"MCP host exited before answering {request_id}\nstderr:\n{self.stderr}" + ) + + line = line.strip() + if not line: + continue + + try: + frame = json.loads(line) + except json.JSONDecodeError: + raise SmokeFailure(f"non-protocol output on stdout: {line}") + + if frame.get("id") != request_id: + continue # notification or out-of-order response + + if "error" in frame: + raise SmokeFailure(f"{request_id} failed: {json.dumps(frame['error'])}") + + return frame["result"] + finally: + timer.cancel() + + def close(self): + try: + self._process.stdin.close() + self._process.wait(timeout=10) + except Exception: + self._process.kill() + + +def initialize(process): + result = process.request( + "initialize", + { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "tp-mcp-smoke", "version": "1.0.0"}, + }, + ) + if "protocolVersion" not in result or "capabilities" not in result: + raise SmokeFailure(f"initialize did not negotiate a session: {json.dumps(result)}") + if "tools" not in result["capabilities"]: + raise SmokeFailure("server did not advertise the tools capability") + process.notify("notifications/initialized") + return result + + +def list_tools(process): + names = [] + cursor = None + while True: + params = {"cursor": cursor} if cursor else {} + result = process.request("tools/list", params) + names.extend(tool["name"] for tool in result["tools"]) + cursor = result.get("nextCursor") + if not cursor: + return names + + +def call_tool(process, name, arguments): + result = process.request("tools/call", {"name": name, "arguments": arguments}) + + if result.get("isError"): + raise SmokeFailure(f"{name} returned isError: {json.dumps(result)}") + + text = next( + (block["text"] for block in result.get("content", []) if block.get("type") == "text"), + None, + ) + if text is None: + raise SmokeFailure(f"{name} returned no text content: {json.dumps(result)}") + + payload = json.loads(text) + # Tools still answer some failures with a legacy embedded error payload. + if isinstance(payload, dict) and "error" in payload: + raise SmokeFailure(f"{name} returned an embedded error: {payload['error']}") + + return payload + + +def create_tool(process, name, arguments): + try: + return call_tool(process, name, arguments) + except SmokeFailure as failure: + raise SmokeFailure( + f"{failure}\n" + " The MCP write path is not yet the CLI's: create_timesheet omits the sell price " + "the CLI resolves from the client rate, so TimePro rejects it." + ) from failure + + +def assert_non_production(tp_command): + info = subprocess.run( + tp_command + ["tenant", "info", "--tenant", TENANT, "--json"], + capture_output=True, + text=True, + check=True, + ) + tenant = json.loads(info.stdout) + api_url = tenant.get("apiUrl", "") + if tenant.get("isProduction", True): + raise SmokeFailure(f"refusing to run: tenant {TENANT} resolves to production ({api_url})") + print(f" tenant {TENANT} resolves to a non-production host: {api_url}") + return api_url + + +def check_discovery(names): + if not DISCOVERY_GOLDEN.exists(): + raise SmokeFailure(f"discovery golden missing: {DISCOVERY_GOLDEN}") + + expected = {tool["name"] for tool in json.loads(DISCOVERY_GOLDEN.read_text())["tools"]} + missing = sorted(expected - set(names)) + if missing: + raise SmokeFailure(f"tools/list is missing golden tools: {missing}") + + print(f" tools/list returned {len(names)} tools, all {len(expected)} default tools present") + + +def most_recent_weekday(): + day = datetime.date.today() + while day.weekday() > 4: + day -= datetime.timedelta(days=1) + return day.isoformat() + + +def main(): + tp_command = json.loads(os.environ["TIMEPRO_MCP_SMOKE_TP"]) + api_url = assert_non_production(tp_command) + + token = uuid.uuid4().hex[:8] + note = f"MCP smoke {token}, safe to delete" + date = most_recent_weekday() + created_id = None + + process = McpProcess(tp_command + ["mcp", "--tenant", TENANT]) + try: + session = initialize(process) + print(f" initialize negotiated protocol {session['protocolVersion']}") + + check_discovery(list_tools(process)) + + projects = call_tool(process, "get_projects_for_client", {"clientId": CLIENT_ID}) + project = next((p for p in projects if p.get("value") == PROJECT_ID), None) + if project is None: + raise SmokeFailure(f"project {PROJECT_ID} not found for client {CLIENT_ID}") + print(f" project {PROJECT_ID} found: {project.get('displayText')}") + + iterations = call_tool(process, "list_iterations", {"projectId": PROJECT_ID}) + if not iterations: + raise SmokeFailure(f"project {PROJECT_ID} returned no iterations") + iteration_id = iterations[0]["iterationId"] + print(f" using iteration {iteration_id} ({iterations[0].get('iterationName')})") + + rate = call_tool(process, "get_client_rate", {"clientId": CLIENT_ID, "date": date}) + if rate is None: + raise SmokeFailure(f"no client rate for {CLIENT_ID} on {date}") + print(f" client rate present for {CLIENT_ID}") + + before = call_tool(process, "get_timesheets", {"date": date}) + print(f" {len(before)} existing entries on {date}") + + # The write goes through MCP on purpose. create_timesheet does not send a sell price, so + # a server that cannot derive one answers 400 and this gate stays red until the shared + # create orchestration lands. + create_tool( + process, + "create_timesheet", + { + "clientId": CLIENT_ID, + "projectId": PROJECT_ID, + "date": date, + "startTime": "04:00", + "endTime": "04:15", + "description": note, + "categoryId": CATEGORY_ID, + "iterationId": iteration_id, + }, + ) + + after = call_tool(process, "get_timesheets", {"date": date}) + matches = [entry for entry in after if entry.get("notes") == note] + if len(matches) != 1: + raise SmokeFailure( + f"expected exactly one entry noted '{note}' on {date}, found {len(matches)}" + ) + + created = matches[0] + created_id = created["timeId"] + for field, expected in ( + ("clientId", CLIENT_ID), + ("projectId", PROJECT_ID), + ("date", date), + ): + if created.get(field) != expected: + raise SmokeFailure( + f"created entry {created_id} has {field}={created.get(field)!r}, expected {expected!r}" + ) + print(f" created and read back entry {created_id} on project {PROJECT_ID}") + + call_tool(process, "delete_timesheet", {"timesheetId": created_id, "date": date}) + + remaining = call_tool(process, "get_timesheets", {"date": date}) + if any(entry.get("timeId") == created_id for entry in remaining): + raise SmokeFailure(f"entry {created_id} still present after delete") + created_id = None + print(" deleted the smoke entry and verified its absence") + + print(f"MCP smoke passed against {api_url}") + return 0 + except SmokeFailure as failure: + print(f"MCP smoke FAILED: {failure}", file=sys.stderr) + if created_id is not None: + print( + f"LEFTOVER TEST DATA: timesheet {created_id} on {date} " + f"(note '{note}') was not deleted", + file=sys.stderr, + ) + if process.stderr: + print(f"server stderr:\n{process.stderr}", file=sys.stderr) + return 1 + finally: + process.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/e2e/test-mcp-smoke.sh b/scripts/e2e/test-mcp-smoke.sh new file mode 100755 index 0000000..dd9b728 --- /dev/null +++ b/scripts/e2e/test-mcp-smoke.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Staging MCP stdio smoke: discovery against the checked-in golden, Northwind reads, one marked +# write on a project that uses iterations, and cleanup. Cleanup failure fails the run and names +# the leftover row. +# +# TIMEPRO_MCP_SMOKE_TP how to invoke the candidate CLI (default: tp) +# TIMEPRO_MCP_SMOKE_TENANT tenant config to bind (default: ssw-staging) +# TIMEPRO_MCP_SMOKE_PROJECT Northwind project that uses iterations (default: 8W52M2) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo " Testing: tp mcp (stdio) against staging" + +if ! command -v python3 &> /dev/null; then + echo " SKIPPED: python3 not found" + exit 0 +fi + +TP_COMMAND=${TIMEPRO_MCP_SMOKE_TP:-tp} + +# Serialise the command as JSON so "dotnet run --project ... --" works as well as a bare "tp". +TIMEPRO_MCP_SMOKE_TP=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1].split()))' "$TP_COMMAND") +export TIMEPRO_MCP_SMOKE_TP + +python3 "$SCRIPT_DIR/mcp_smoke.py" diff --git a/src/SSW.TimePro.Cli/Infrastructure/Config/ConfigPaths.cs b/src/SSW.TimePro.Cli/Infrastructure/Config/ConfigPaths.cs index 75b5e56..509b06b 100644 --- a/src/SSW.TimePro.Cli/Infrastructure/Config/ConfigPaths.cs +++ b/src/SSW.TimePro.Cli/Infrastructure/Config/ConfigPaths.cs @@ -5,9 +5,15 @@ namespace SSW.TimePro.Cli.Infrastructure.Config; /// public static class ConfigPaths { - private static readonly string ConfigHome = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config", "timepro-cli"); + /// + /// Overrides the config root. Exists so tests and MCP hosts can run against an isolated + /// config directory instead of the developer's real ~/.config/timepro-cli. + /// + public const string ConfigDirEnvVar = "TIMEPRO_CLI_CONFIG_DIR"; + + private static readonly string ConfigHome = Resolve( + Environment.GetEnvironmentVariable(ConfigDirEnvVar), + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); public static string Root => ConfigHome; public static string GlobalConfigFile => Path.Combine(ConfigHome, "config.json"); @@ -18,6 +24,15 @@ public static class ConfigPaths public static string TenantConfigFile(string tenantId) => Path.Combine(TenantsDir, $"{tenantId.ToLowerInvariant()}.json"); + internal static string Resolve(string? configDirOverride, string userProfile) + { + if (string.IsNullOrWhiteSpace(configDirOverride)) + return Path.Combine(userProfile, ".config", "timepro-cli"); + + var expanded = Paths.PathExpander.ExpandHomeDirectory(configDirOverride.Trim()); + return Path.GetFullPath(expanded); + } + /// /// Ensures all config directories exist. /// diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/delete_timesheet.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/delete_timesheet.json new file mode 100644 index 0000000..68555e2 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/delete_timesheet.json @@ -0,0 +1,8 @@ +{ + "content": [ + { + "text": "{\n \u0022success\u0022: true,\n \u0022timesheetId\u0022: 4242\n}", + "type": "text" + } + ] +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/get_client_rate.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/get_client_rate.json new file mode 100644 index 0000000..53cce97 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/get_client_rate.json @@ -0,0 +1,8 @@ +{ + "content": [ + { + "text": "{\n \u0022empId\u0022: \u0022BOB\u0022,\n \u0022clientId\u0022: \u0022NWIND\u0022,\n \u0022rate\u0022: 175,\n \u0022prepaidRate\u0022: 150,\n \u0022clientRateId\u0022: 900,\n \u0022employeeName\u0022: \u0022Bob Northwind\u0022,\n \u0022clientName\u0022: \u0022Northwind Traders\u0022,\n \u0022expiryDate\u0022: \u00222026-12-31\u0022,\n \u0022notes\u0022: \u0022Standard rate\u0022\n}", + "type": "text" + } + ] +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/get_leave_entries.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/get_leave_entries.json new file mode 100644 index 0000000..ae11e92 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/get_leave_entries.json @@ -0,0 +1,8 @@ +{ + "content": [ + { + "text": "[\n {\n \u0022id\u0022: \u00220f2b7e7a-1111-4444-8888-aaaaaaaaaaaa\u0022,\n \u0022startDate\u0022: \u00222026-04-01T00:00:00\\u002B10:00\u0022,\n \u0022endDate\u0022: \u00222026-04-01T23:59:00\\u002B10:00\u0022,\n \u0022createdAt\u0022: \u00222026-03-01T09:00:00\\u002B10:00\u0022,\n \u0022updatedAt\u0022: \u00222026-03-02T09:00:00\\u002B10:00\u0022,\n \u0022note\u0022: \u0022Family day\u0022,\n \u0022approvedBy\u0022: \u0022bob@northwind.example\u0022,\n \u0022optionalEmp\u0022: [\n \u0022bob@northwind.example\u0022\n ],\n \u0022requestedEmpId\u0022: \u0022BOB\u0022,\n \u0022userStartTime\u0022: \u002209:00\u0022,\n \u0022userEndTime\u0022: \u002218:00\u0022,\n \u0022daysAway\u0022: 1,\n \u0022timeLessOverride\u0022: null,\n \u0022cancellationReason\u0022: null,\n \u0022startDateWithoutOffset\u0022: null,\n \u0022endDateWithoutOffset\u0022: null,\n \u0022allDay\u0022: true,\n \u0022length\u0022: 1,\n \u0022leaveType\u0022: {\n \u0022id\u0022: 1,\n \u0022name\u0022: \u0022Annual Leave\u0022,\n \u0022isActive\u0022: true\n },\n \u0022status\u0022: 1,\n \u0022statusName\u0022: \u0022Pending\u0022\n }\n]", + "type": "text" + } + ] +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/get_timesheets.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/get_timesheets.json new file mode 100644 index 0000000..f85812c --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/get_timesheets.json @@ -0,0 +1,8 @@ +{ + "content": [ + { + "text": "[\n {\n \u0022timeId\u0022: 4242,\n \u0022empId\u0022: \u0022BOB\u0022,\n \u0022empName\u0022: \u0022Bob Northwind\u0022,\n \u0022client\u0022: \u0022Northwind Traders\u0022,\n \u0022clientId\u0022: \u0022NWIND\u0022,\n \u0022project\u0022: \u0022Northwind Traders\u0022,\n \u0022projectId\u0022: \u00221I776Q\u0022,\n \u0022date\u0022: \u00222026-03-16\u0022,\n \u0022startTime\u0022: \u00222026-03-16T09:00:00\u0022,\n \u0022endTime\u0022: \u00222026-03-16T17:00:00\u0022,\n \u0022totalTime\u0022: 7.5,\n \u0022location\u0022: \u0022Home\u0022,\n \u0022billableId\u0022: \u0022B\u0022,\n \u0022isSuggested\u0022: false,\n \u0022notes\u0022: \u0022Product search\u0022,\n \u0022isLocked\u0022: false,\n \u0022invoiceId\u0022: null\n },\n {\n \u0022timeId\u0022: 4243,\n \u0022empId\u0022: \u0022BOB\u0022,\n \u0022empName\u0022: \u0022Bob Northwind\u0022,\n \u0022client\u0022: \u0022Northwind Traders\u0022,\n \u0022clientId\u0022: \u0022NWIND\u0022,\n \u0022project\u0022: \u0022Northwind Traders\u0022,\n \u0022projectId\u0022: \u00221I776Q\u0022,\n \u0022date\u0022: \u00222026-03-16\u0022,\n \u0022startTime\u0022: \u00222026-03-16T13:00:00\u0022,\n \u0022endTime\u0022: \u00222026-03-16T14:00:00\u0022,\n \u0022totalTime\u0022: 1,\n \u0022location\u0022: \u0022SSW\u0022,\n \u0022billableId\u0022: \u0022B\u0022,\n \u0022isSuggested\u0022: true,\n \u0022notes\u0022: \u0022Order history\u0022,\n \u0022isLocked\u0022: false,\n \u0022invoiceId\u0022: null\n }\n]", + "type": "text" + } + ] +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/list_iterations.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/list_iterations.json new file mode 100644 index 0000000..ab01c0f --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/list_iterations.json @@ -0,0 +1,8 @@ +{ + "content": [ + { + "text": "[\n {\n \u0022iterationId\u0022: 3402,\n \u0022iterationName\u0022: \u0022Checkout API\u0022,\n \u0022lastUsedDate\u0022: \u00222026-03-09\u0022\n },\n {\n \u0022iterationId\u0022: 3403,\n \u0022iterationName\u0022: \u0022Order history\u0022,\n \u0022lastUsedDate\u0022: \u00222026-03-13\u0022\n }\n]", + "type": "text" + } + ] +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/update_timesheet.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/update_timesheet.json new file mode 100644 index 0000000..5acad46 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Calls/update_timesheet.json @@ -0,0 +1,8 @@ +{ + "content": [ + { + "text": "{\n \u0022success\u0022: true,\n \u0022timesheetId\u0022: 4242,\n \u0022message\u0022: null,\n \u0022timesheet\u0022: {\n \u0022timeId\u0022: 4242,\n \u0022empId\u0022: \u0022BOB\u0022,\n \u0022empName\u0022: \u0022Bob Northwind\u0022,\n \u0022client\u0022: \u0022Northwind Traders\u0022,\n \u0022clientId\u0022: \u0022NWIND\u0022,\n \u0022project\u0022: \u0022Northwind Traders\u0022,\n \u0022projectId\u0022: \u00221I776Q\u0022,\n \u0022iteration\u0022: \u0022Checkout API\u0022,\n \u0022iterationId\u0022: null,\n \u0022category\u0022: \u0022Web development\u0022,\n \u0022location\u0022: \u0022Home\u0022,\n \u0022locationId\u0022: \u0022Home\u0022,\n \u0022notes\u0022: \u0022Product search\u0022,\n \u0022date\u0022: \u00222026-03-16T00:00:00\u0022,\n \u0022startTime\u0022: \u00222026-03-16T09:00:00\u0022,\n \u0022endTime\u0022: \u00222026-03-16T17:00:00\u0022,\n \u0022billableId\u0022: \u0022B\u0022,\n \u0022isBillable\u0022: true,\n \u0022less\u0022: 0.5,\n \u0022totalTime\u0022: 7.5,\n \u0022hasNotes\u0022: true,\n \u0022isSuggested\u0022: false,\n \u0022isLeave\u0022: false,\n \u0022inputSource\u0022: null,\n \u0022invoiceId\u0022: null,\n \u0022invoiceType\u0022: null,\n \u0022isLocked\u0022: false\n },\n \u0022iterationApplied\u0022: null,\n \u0022warning\u0022: null\n}", + "type": "text" + } + ] +} 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 new file mode 100644 index 0000000..91a6b6f --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Discovery/tools-list.accounting.json @@ -0,0 +1,1501 @@ +{ + "count": 47, + "tools": [ + { + "description": "Accept a suggested timesheet, converting it into a real timesheet. Returns the entry as saved.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "date": { + "default": null, + "description": "Date the suggestion is on (yyyy-MM-dd). Speeds up the lookup; otherwise recent weeks are searched.", + "type": [ + "string", + "null" + ] + }, + "iteration": { + "default": null, + "description": "Iteration/sprint for the accepted timesheet, by name or ID. Required for projects that use iterations.", + "type": [ + "string", + "null" + ] + }, + "location": { + "default": null, + "description": "Override location", + "type": [ + "string", + "null" + ] + }, + "notes": { + "default": null, + "description": "Override notes", + "type": [ + "string", + "null" + ] + }, + "suggestedId": { + "description": "Suggested timesheet ID", + "type": "integer" + } + }, + "required": [ + "suggestedId" + ], + "type": "object" + }, + "name": "accept_suggested_timesheet" + }, + { + "description": "Validate a week of timesheets for gaps and issues (leave-aware). Returns per-day coverage with hours, leave, issues, plus allCovered. week: 0=this week (default), -1=last week. The single most useful tool for confirming a week is complete before submitting.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "empId": { + "default": null, + "description": "empId to check. Defaults to the current user\u0027s empId.", + "type": [ + "string", + "null" + ] + }, + "employeeId": { + "default": null, + "description": "Alias for empId.", + "type": [ + "string", + "null" + ] + }, + "week": { + "default": 0, + "description": "Week offset. 0=this week (default), -1=last week.", + "type": "integer" + } + }, + "type": "object" + }, + "name": "check_week" + }, + { + "description": "Create a leave 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.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "approvedBy": { + "default": null, + "description": "Approver\u0027s email address", + "type": [ + "string", + "null" + ] + }, + "cc": { + "default": null, + "description": "Comma-separated list of emails to notify", + "type": [ + "string", + "null" + ] + }, + "dryRun": { + "default": false, + "description": "Validate and return the proposed request without creating leave", + "type": "boolean" + }, + "end": { + "description": "End date (yyyy-MM-dd)", + "type": "string" + }, + "endTime": { + "default": null, + "description": "End time override (HH:mm, default 18:00)", + "type": [ + "string", + "null" + ] + }, + "halfDay": { + "default": false, + "description": "Request partial-day leave; start and end must be the same day", + "type": "boolean" + }, + "note": { + "description": "Leave note/reason", + "type": "string" + }, + "start": { + "description": "Start date (yyyy-MM-dd)", + "type": "string" + }, + "startTime": { + "default": null, + "description": "Start time override (HH:mm, default 09:00)", + "type": [ + "string", + "null" + ] + }, + "timeZoneId": { + "default": null, + "description": "Timezone override (IANA or Windows ID); takes priority over the TimePro user profile timezone", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Leave type ID or active leave type name", + "type": "string" + } + }, + "required": [ + "start", + "end", + "type", + "note" + ], + "type": "object" + }, + "name": "create_leave" + }, + { + "description": "Create a new timesheet entry. Some projects require an iteration ID \u2014 use ListIterations to check and find the correct ID.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "billableId": { + "default": "B", + "description": "Billable type: B (billable), BPP (prepaid), W (write-off)", + "type": "string" + }, + "categoryId": { + "default": null, + "description": "Category ID (e.g., TRAIN, PresDe)", + "type": [ + "string", + "null" + ] + }, + "clientId": { + "description": "Client ID", + "type": "string" + }, + "date": { + "description": "Date (yyyy-MM-dd)", + "type": "string" + }, + "description": { + "default": null, + "description": "Notes/description", + "type": [ + "string", + "null" + ] + }, + "endTime": { + "default": "17:00", + "description": "End time (HH:mm)", + "type": "string" + }, + "iterationId": { + "default": null, + "description": "Iteration/sprint ID. Required for projects that use iterations (e.g., 1I776Q). Use ListIterations to find the ID.", + "type": [ + "integer", + "null" + ] + }, + "location": { + "default": null, + "description": "Location (e.g., Office, Home)", + "type": [ + "string", + "null" + ] + }, + "projectId": { + "description": "Project ID", + "type": "string" + }, + "startTime": { + "default": "09:00", + "description": "Start time (HH:mm)", + "type": "string" + } + }, + "required": [ + "clientId", + "projectId", + "date" + ], + "type": "object" + }, + "name": "create_timesheet" + }, + { + "description": "Delete a timesheet entry. Suggestions cannot be deleted; accept one first.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "date": { + "default": null, + "description": "Date the timesheet is on (yyyy-MM-dd). Speeds up the lookup; otherwise recent weeks are searched.", + "type": [ + "string", + "null" + ] + }, + "timesheetId": { + "description": "Timesheet ID", + "type": "integer" + } + }, + "required": [ + "timesheetId" + ], + "type": "object" + }, + "name": "delete_timesheet" + }, + { + "description": "Get the aged-debtor view for a client: outstanding invoices with days overdue.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "clientId": { + "description": "Client ID", + "type": "string" + } + }, + "required": [ + "clientId" + ], + "type": "object" + }, + "name": "get_client_outstanding" + }, + { + "description": "Get the current employee\u0027s billing rate for a client. Includes rate expiry info.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "clientId": { + "description": "Client ID", + "type": "string" + }, + "date": { + "default": null, + "description": "Date for rate lookup (yyyy-MM-dd). Defaults to today.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "clientId" + ], + "type": "object" + }, + "name": "get_client_rate" + }, + { + "description": "List clients that have outstanding (unbilled) time, with OS balance and earliest unallocated timesheet date.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "get_clients_with_outstanding_time" + }, + { + "description": "Get CRM bookings/appointments for a date range.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "endDate": { + "description": "End date (yyyy-MM-dd)", + "type": "string" + }, + "startDate": { + "description": "Start date (yyyy-MM-dd)", + "type": "string" + } + }, + "required": [ + "startDate", + "endDate" + ], + "type": "object" + }, + "name": "get_crm_bookings" + }, + { + "description": "Get the current user (identity, tenant, display name) \u2014 handy for showing who an MCP session is authenticated as.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "get_current_user" + }, + { + "description": "Get a single invoice header. GST convention: SubTotal is ex-GST, SalesTaxAmt is GST, SellTotal is inc-GST; SalesTaxPct is the raw API tax rate and should be normalized before calculations.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "invoiceId": { + "description": "Invoice ID", + "type": "integer" + } + }, + "required": [ + "invoiceId" + ], + "type": "object" + }, + "name": "get_invoice" + }, + { + "description": "List line items (products) on an invoice. SellAmt and SellTotal are ex-GST; SalesTaxAmt is the GST component when returned.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "invoiceId": { + "description": "Invoice ID", + "type": "integer" + } + }, + "required": [ + "invoiceId" + ], + "type": "object" + }, + "name": "get_invoice_lines" + }, + { + "description": "List receipts (payments) against a specific invoice.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "invoiceId": { + "description": "Invoice ID", + "type": "integer" + } + }, + "required": [ + "invoiceId" + ], + "type": "object" + }, + "name": "get_invoice_receipts" + }, + { + "description": "List timesheets billed on an invoice. SellTotal, BillableAmount, and Amount are treated as ex-GST; SalesTaxAmt/SalesTaxPct provide the tax component/rate when returned. Set type=\u0027writeoff\u0027 for written-off timesheets instead of allocated.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "invoiceId": { + "description": "Invoice ID", + "type": "integer" + }, + "type": { + "default": "allocated", + "description": "Either \u0027allocated\u0027 (default) or \u0027writeoff\u0027", + "type": "string" + } + }, + "required": [ + "invoiceId" + ], + "type": "object" + }, + "name": "get_invoice_timesheets" + }, + { + "description": "List all invoices for a client (full history, unpaged). Invoice amount convention: SubTotal ex-GST, SalesTaxAmt GST, SellTotal inc-GST.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "clientId": { + "description": "Client ID", + "type": "string" + } + }, + "required": [ + "clientId" + ], + "type": "object" + }, + "name": "get_invoices_by_client" + }, + { + "description": "Get leave stats for an employee: days since last leave and total leave hours taken in the last 12 months. Defaults to the current user\u0027s empId. (TimePro does not expose entitlement/remaining per leave type.)", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "empId": { + "default": null, + "description": "empId to read. Defaults to the current user\u0027s empId.", + "type": [ + "string", + "null" + ] + }, + "employeeId": { + "default": null, + "description": "Alias for empId.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "name": "get_leave_balance" + }, + { + "annotations": { + "destructiveHint": false, + "readOnlyHint": true + }, + "description": "Report when TimePro\u0027s leave balances were last imported from Xero, how many employees have a stored balance, and whether the data is stale. Read-only. Check this before importing so you can tell the user whether a re-import is actually needed.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "get_leave_balance_status" + }, + { + "description": "List EasyLeave entries. Use empId for one person; employeeId is accepted as an alias. Omit both to return all visible leave.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "empId": { + "default": null, + "description": "empId to filter by", + "type": [ + "string", + "null" + ] + }, + "employeeId": { + "default": null, + "description": "Alias for empId", + "type": [ + "string", + "null" + ] + }, + "filter": { + "default": "UPCOMING", + "description": "Filter: UPCOMING (default) or PAST", + "type": "string" + }, + "limit": { + "default": 10, + "description": "Number of entries to return", + "type": "integer" + } + }, + "type": "object" + }, + "name": "get_leave_entries" + }, + { + "description": "Get the WFH/location defaults and repo mapping for a given path.", + "inputSchema": { + "properties": { + "repoPath": { + "default": null, + "description": "Repository path to check for mapping", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "name": "get_location_and_mapping" + }, + { + "description": "Get structured prepaid drawdown totals for an invoice: original, drawnDown, credited, and remaining amounts split into exGst/gst/incGst. remaining.exGst is sourced from the existing client invoice table endpoint\u0027s ledger-backed RemainingPrepaidCredit.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "invoiceId": { + "description": "Prepaid invoice ID", + "type": "integer" + } + }, + "required": [ + "invoiceId" + ], + "type": "object" + }, + "name": "get_prepaid_status" + }, + { + "description": "Download the prepaid drawdown status report PDF for an invoice. Returns base64-encoded bytes inside a JSON envelope \u2014 decode on the client side to save as .pdf. Prefer GetPrepaidStatus for structured totals.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "invoiceId": { + "description": "Prepaid invoice ID", + "type": "integer" + }, + "templateId": { + "default": 0, + "description": "Report template ID (0 = tenant default)", + "type": "integer" + } + }, + "required": [ + "invoiceId" + ], + "type": "object" + }, + "name": "get_prepaid_status_pdf" + }, + { + "description": "Get a single product by ID.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "productId": { + "description": "Product ID", + "type": "string" + } + }, + "required": [ + "productId" + ], + "type": "object" + }, + "name": "get_product" + }, + { + "description": "Show product discounts configured for a client.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "clientId": { + "description": "Client ID", + "type": "string" + } + }, + "required": [ + "clientId" + ], + "type": "object" + }, + "name": "get_product_discounts_for_client" + }, + { + "description": "Get projects for a client. Returns project IDs and names.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "clientId": { + "description": "Client ID", + "type": "string" + } + }, + "required": [ + "clientId" + ], + "type": "object" + }, + "name": "get_projects_for_client" + }, + { + "description": "Project-hours summary for one empId over a period (billable vs non-billable across projects). Defaults to the current user; employeeId is accepted as an alias.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "empId": { + "default": null, + "description": "empId to summarize. Defaults to the current user\u0027s empId.", + "type": [ + "string", + "null" + ] + }, + "employeeId": { + "default": null, + "description": "Alias for empId", + "type": [ + "string", + "null" + ] + }, + "endDate": { + "description": "End date (yyyy-MM-dd)", + "type": "string" + }, + "startDate": { + "description": "Start date (yyyy-MM-dd)", + "type": "string" + } + }, + "required": [ + "startDate", + "endDate" + ], + "type": "object" + }, + "name": "get_projects_summary" + }, + { + "description": "Get a receipt with its invoice allocations (how the payment is split across invoices).", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "receiptId": { + "description": "Receipt ID", + "type": "integer" + } + }, + "required": [ + "receiptId" + ], + "type": "object" + }, + "name": "get_receipt_detail" + }, + { + "description": "Get a recurring invoice template (includes product lines).", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "recurringId": { + "description": "Recurring invoice ID", + "type": "integer" + } + }, + "required": [ + "recurringId" + ], + "type": "object" + }, + "name": "get_recurring_invoice" + }, + { + "description": "Get suggested timesheets for a date. Refreshes suggestions first.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "date": { + "description": "Date (yyyy-MM-dd)", + "type": "string" + } + }, + "required": [ + "date" + ], + "type": "object" + }, + "name": "get_suggested_timesheets" + }, + { + "description": "Get timesheets for a date or date range. Use empId to read another employee\u0027s timesheets; employeeId is accepted as an alias.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "date": { + "description": "Single date or start date (yyyy-MM-dd)", + "type": "string" + }, + "empId": { + "default": null, + "description": "empId to read. Defaults to the current user\u0027s empId.", + "type": [ + "string", + "null" + ] + }, + "employeeId": { + "default": null, + "description": "Alias for empId.", + "type": [ + "string", + "null" + ] + }, + "endDate": { + "default": null, + "description": "End date for range (yyyy-MM-dd). If omitted, returns single day.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "date" + ], + "type": "object" + }, + "name": "get_timesheets" + }, + { + "description": "List unbilled (unallocated) timesheets for a client - revenue still in the pipeline. SellTotal, BillableAmount, and Amount are treated as ex-GST.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "clientId": { + "description": "Client ID", + "type": "string" + }, + "direction": { + "default": null, + "description": "Sort direction", + "type": [ + "string", + "null" + ] + }, + "pageSize": { + "default": null, + "description": "Page size", + "type": [ + "integer", + "null" + ] + }, + "skip": { + "default": null, + "description": "Rows to skip", + "type": [ + "integer", + "null" + ] + }, + "sortField": { + "default": null, + "description": "Sort field (e.g. DateCreated)", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "clientId" + ], + "type": "object" + }, + "name": "get_unbilled_timesheets_for_client" + }, + { + "description": "List unpaid invoices for a client. Invoice amount convention: SubTotal ex-GST, SalesTaxAmt GST, SellTotal inc-GST.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "clientId": { + "description": "Client ID", + "type": "string" + } + }, + "required": [ + "clientId" + ], + "type": "object" + }, + "name": "get_unpaid_invoices_by_client" + }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "readOnlyHint": false + }, + "description": "Import leave balances for EVERY employee from a Xero \u0027Leave Balances\u0027 CSV export, replacing what TimePro currently stores. No dry run and no undo, so confirm with the user first. Pass the path to the CSV file, not its contents. Always report the returned unmatchedEmployees (rows skipped) and warnings - the import succeeds despite them.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "csvPath": { + "description": "Path to the Xero \u0027Leave Balances\u0027 CSV export on this machine", + "type": "string" + } + }, + "required": [ + "csvPath" + ], + "type": "object" + }, + "name": "import_leave_balances" + }, + { + "description": "List all SKUs. Set isPrepaid=true to list only prepaid SKUs (useful for prepaid drawdown analysis).", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "isPrepaid": { + "default": false, + "description": "Filter to prepaid SKUs only", + "type": "boolean" + } + }, + "type": "object" + }, + "name": "list_all_skus" + }, + { + "description": "List timesheet billable-type codes (B = billable, BPP = prepaid, W = write-off, etc.).", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "list_billable_types" + }, + { + "description": "List timesheet category codes (for labelling timesheets when creating or filtering queries).", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "list_categories" + }, + { + "description": "List all configured client rates (all empIds by default). For just the current user\u0027s rate for a client, prefer GetClientRate. employeeId is accepted as an alias for empId.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "clientId": { + "description": "Client ID", + "type": "string" + }, + "direction": { + "default": "desc", + "description": "Sort direction (asc/desc)", + "type": [ + "string", + "null" + ] + }, + "empId": { + "default": null, + "description": "Filter to one empId", + "type": [ + "string", + "null" + ] + }, + "employeeId": { + "default": null, + "description": "Alias for empId", + "type": [ + "string", + "null" + ] + }, + "pageSize": { + "default": 100, + "description": "Page size (default 100)", + "type": [ + "integer", + "null" + ] + }, + "showExpired": { + "default": false, + "description": "Include expired rates", + "type": "boolean" + }, + "skip": { + "default": 0, + "description": "Rows to skip", + "type": [ + "integer", + "null" + ] + }, + "sortField": { + "default": "ExpiryDate", + "description": "Sort field (default ExpiryDate)", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "clientId" + ], + "type": "object" + }, + "name": "list_client_rates" + }, + { + "description": "List credit notes for a client.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "clientId": { + "description": "Client ID", + "type": "string" + } + }, + "required": [ + "clientId" + ], + "type": "object" + }, + "name": "list_credit_notes" + }, + { + "description": "List invoices (paged). Filter by query text, sort by DateCreated/DateInvoiced/SellTotal/ClientID. SellTotal is GST-inclusive. Set onlyRecurring=true for recurring-generated invoices.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "dir": { + "default": "desc", + "description": "Sort direction: asc or desc (default desc)", + "type": "string" + }, + "field": { + "default": "DateCreated", + "description": "Sort field (default DateCreated)", + "type": "string" + }, + "limit": { + "default": 50, + "description": "Page size (default 50)", + "type": "integer" + }, + "onlyRecurring": { + "default": false, + "description": "Filter to recurring-generated invoices only", + "type": "boolean" + }, + "query": { + "default": null, + "description": "Free-text search (client name, invoice id)", + "type": [ + "string", + "null" + ] + }, + "skip": { + "default": 0, + "description": "Rows to skip (default 0)", + "type": "integer" + } + }, + "type": "object" + }, + "name": "list_invoices" + }, + { + "description": "List iterations/sprints for a project. Returns empty list if the project doesn\u0027t use iterations. If the list is non-empty, an iteration ID is required when creating timesheets for this project.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "projectId": { + "description": "Project ID (e.g., 1I776Q)", + "type": "string" + } + }, + "required": [ + "projectId" + ], + "type": "object" + }, + "name": "list_iterations" + }, + { + "description": "List timesheet location codes (SSW, Home, Client, Travel, Other).", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "list_locations" + }, + { + "description": "List paid receipts (paged). Sort by PaymentDate descending by default. PaidTotal is NEGATIVE for incoming payments; take abs() for reported sales.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "dir": { + "default": "desc", + "description": "Sort direction", + "type": "string" + }, + "field": { + "default": "PaymentDate", + "description": "Sort field (default PaymentDate)", + "type": "string" + }, + "limit": { + "default": 100, + "description": "Page size (500 is safe)", + "type": "integer" + }, + "searchText": { + "default": null, + "description": "Search text (client/reference)", + "type": [ + "string", + "null" + ] + }, + "skip": { + "default": 0, + "description": "Rows to skip", + "type": "integer" + } + }, + "type": "object" + }, + "name": "list_paid_receipts" + }, + { + "description": "List products. With isExpand=true, each product includes its SKUs inline.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "isExpand": { + "default": false, + "description": "Expand SKUs", + "type": "boolean" + } + }, + "type": "object" + }, + "name": "list_products" + }, + { + "description": "List recurring invoice templates.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "clientId": { + "default": null, + "description": "Filter to one client", + "type": [ + "string", + "null" + ] + }, + "dir": { + "default": "desc", + "description": "Sort direction", + "type": "string" + }, + "field": { + "default": "LastInvEndDate", + "description": "Sort field (default LastInvEndDate)", + "type": "string" + }, + "limit": { + "default": 50, + "description": "Page size", + "type": "integer" + }, + "query": { + "default": null, + "description": "Search text", + "type": [ + "string", + "null" + ] + }, + "showOutdated": { + "default": false, + "description": "Include outdated/stopped templates", + "type": "boolean" + }, + "skip": { + "default": 0, + "description": "Rows to skip", + "type": "integer" + } + }, + "type": "object" + }, + "name": "list_recurring_invoices" + }, + { + "description": "Query timesheets across empIds, clients, projects and a date range. Returns detailed rows including hours and sell price; sell prices/amounts are treated as ex-GST for invoice reconciliation. employeeIds is accepted as an alias for empIds.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "categoryIds": { + "default": null, + "description": "Filter to specific category IDs", + "items": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "array", + "null" + ] + }, + "clientIds": { + "default": null, + "description": "Filter to specific client IDs", + "items": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "array", + "null" + ] + }, + "empIds": { + "default": null, + "description": "Filter to specific empIds (omit for all)", + "items": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "array", + "null" + ] + }, + "employeeIds": { + "default": null, + "description": "Alias for empIds", + "items": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "array", + "null" + ] + }, + "endDate": { + "description": "End date (yyyy-MM-dd)", + "type": "string" + }, + "projectIds": { + "default": null, + "description": "Filter to specific project IDs", + "items": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "array", + "null" + ] + }, + "startDate": { + "description": "Start date (yyyy-MM-dd)", + "type": "string" + } + }, + "required": [ + "startDate", + "endDate" + ], + "type": "object" + }, + "name": "query_timesheets" + }, + { + "description": "Search for clients by name. Returns client IDs and names.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "query": { + "description": "Search text", + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search_clients" + }, + { + "description": "Update an existing leave request for the current user. Unspecified API-returned fields are preserved. List leave entries first to obtain the leave ID.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "approvedBy": { + "default": null, + "description": "New approver email; omit to preserve", + "type": [ + "string", + "null" + ] + }, + "cc": { + "default": null, + "description": "Comma-separated CC recipients; omit to preserve", + "type": [ + "string", + "null" + ] + }, + "clearApprovedBy": { + "default": false, + "description": "Remove the current approver", + "type": "boolean" + }, + "clearCc": { + "default": false, + "description": "Remove all current CC recipients", + "type": "boolean" + }, + "dryRun": { + "default": false, + "description": "Validate and return the proposed update without applying it", + "type": "boolean" + }, + "end": { + "default": null, + "description": "New end date (yyyy-MM-dd); omit to preserve", + "type": [ + "string", + "null" + ] + }, + "endTime": { + "default": null, + "description": "Employee workday end time (HH:mm); omit to preserve it when returned, otherwise use the profile value", + "type": [ + "string", + "null" + ] + }, + "halfDay": { + "default": null, + "description": "true for partial-day, false for full-day, omit to preserve", + "type": [ + "boolean", + "null" + ] + }, + "id": { + "description": "Leave entry ID (GUID)", + "type": "string" + }, + "note": { + "default": null, + "description": "New leave note/reason; omit to preserve", + "type": [ + "string", + "null" + ] + }, + "start": { + "default": null, + "description": "New start date (yyyy-MM-dd); omit to preserve", + "type": [ + "string", + "null" + ] + }, + "startTime": { + "default": null, + "description": "Employee workday start time (HH:mm); omit to preserve it when returned, otherwise use the profile value", + "type": [ + "string", + "null" + ] + }, + "timeZoneId": { + "default": null, + "description": "Timezone for changed dates (IANA or Windows ID)", + "type": [ + "string", + "null" + ] + }, + "type": { + "default": null, + "description": "New leave type ID or active leave type name; omit to preserve", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "name": "update_leave" + }, + { + "description": "Update an existing timesheet. Only specify fields you want to change; everything else is preserved. Returns the entry as saved.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "billableId": { + "default": null, + "description": "New billable type: B, BPP, W", + "type": [ + "string", + "null" + ] + }, + "date": { + "default": null, + "description": "Date the timesheet is on (yyyy-MM-dd). Speeds up the lookup; otherwise recent weeks are searched.", + "type": [ + "string", + "null" + ] + }, + "description": { + "default": null, + "description": "New notes/description", + "type": [ + "string", + "null" + ] + }, + "iteration": { + "default": null, + "description": "New iteration/sprint, by name or ID. Use ListIterations to see the options.", + "type": [ + "string", + "null" + ] + }, + "location": { + "default": null, + "description": "New location", + "type": [ + "string", + "null" + ] + }, + "timesheetId": { + "description": "Timesheet ID", + "type": "integer" + } + }, + "required": [ + "timesheetId" + ], + "type": "object" + }, + "name": "update_timesheet" + } + ] +} 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 new file mode 100644 index 0000000..60c4cc0 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Discovery/tools-list.default.json @@ -0,0 +1,717 @@ +{ + "count": 18, + "tools": [ + { + "description": "Accept a suggested timesheet, converting it into a real timesheet. Returns the entry as saved.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "date": { + "default": null, + "description": "Date the suggestion is on (yyyy-MM-dd). Speeds up the lookup; otherwise recent weeks are searched.", + "type": [ + "string", + "null" + ] + }, + "iteration": { + "default": null, + "description": "Iteration/sprint for the accepted timesheet, by name or ID. Required for projects that use iterations.", + "type": [ + "string", + "null" + ] + }, + "location": { + "default": null, + "description": "Override location", + "type": [ + "string", + "null" + ] + }, + "notes": { + "default": null, + "description": "Override notes", + "type": [ + "string", + "null" + ] + }, + "suggestedId": { + "description": "Suggested timesheet ID", + "type": "integer" + } + }, + "required": [ + "suggestedId" + ], + "type": "object" + }, + "name": "accept_suggested_timesheet" + }, + { + "description": "Validate a week of timesheets for gaps and issues (leave-aware). Returns per-day coverage with hours, leave, issues, plus allCovered. week: 0=this week (default), -1=last week. The single most useful tool for confirming a week is complete before submitting.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "empId": { + "default": null, + "description": "empId to check. Defaults to the current user\u0027s empId.", + "type": [ + "string", + "null" + ] + }, + "employeeId": { + "default": null, + "description": "Alias for empId.", + "type": [ + "string", + "null" + ] + }, + "week": { + "default": 0, + "description": "Week offset. 0=this week (default), -1=last week.", + "type": "integer" + } + }, + "type": "object" + }, + "name": "check_week" + }, + { + "description": "Create a leave 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.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "approvedBy": { + "default": null, + "description": "Approver\u0027s email address", + "type": [ + "string", + "null" + ] + }, + "cc": { + "default": null, + "description": "Comma-separated list of emails to notify", + "type": [ + "string", + "null" + ] + }, + "dryRun": { + "default": false, + "description": "Validate and return the proposed request without creating leave", + "type": "boolean" + }, + "end": { + "description": "End date (yyyy-MM-dd)", + "type": "string" + }, + "endTime": { + "default": null, + "description": "End time override (HH:mm, default 18:00)", + "type": [ + "string", + "null" + ] + }, + "halfDay": { + "default": false, + "description": "Request partial-day leave; start and end must be the same day", + "type": "boolean" + }, + "note": { + "description": "Leave note/reason", + "type": "string" + }, + "start": { + "description": "Start date (yyyy-MM-dd)", + "type": "string" + }, + "startTime": { + "default": null, + "description": "Start time override (HH:mm, default 09:00)", + "type": [ + "string", + "null" + ] + }, + "timeZoneId": { + "default": null, + "description": "Timezone override (IANA or Windows ID); takes priority over the TimePro user profile timezone", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Leave type ID or active leave type name", + "type": "string" + } + }, + "required": [ + "start", + "end", + "type", + "note" + ], + "type": "object" + }, + "name": "create_leave" + }, + { + "description": "Create a new timesheet entry. Some projects require an iteration ID \u2014 use ListIterations to check and find the correct ID.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "billableId": { + "default": "B", + "description": "Billable type: B (billable), BPP (prepaid), W (write-off)", + "type": "string" + }, + "categoryId": { + "default": null, + "description": "Category ID (e.g., TRAIN, PresDe)", + "type": [ + "string", + "null" + ] + }, + "clientId": { + "description": "Client ID", + "type": "string" + }, + "date": { + "description": "Date (yyyy-MM-dd)", + "type": "string" + }, + "description": { + "default": null, + "description": "Notes/description", + "type": [ + "string", + "null" + ] + }, + "endTime": { + "default": "17:00", + "description": "End time (HH:mm)", + "type": "string" + }, + "iterationId": { + "default": null, + "description": "Iteration/sprint ID. Required for projects that use iterations (e.g., 1I776Q). Use ListIterations to find the ID.", + "type": [ + "integer", + "null" + ] + }, + "location": { + "default": null, + "description": "Location (e.g., Office, Home)", + "type": [ + "string", + "null" + ] + }, + "projectId": { + "description": "Project ID", + "type": "string" + }, + "startTime": { + "default": "09:00", + "description": "Start time (HH:mm)", + "type": "string" + } + }, + "required": [ + "clientId", + "projectId", + "date" + ], + "type": "object" + }, + "name": "create_timesheet" + }, + { + "description": "Delete a timesheet entry. Suggestions cannot be deleted; accept one first.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "date": { + "default": null, + "description": "Date the timesheet is on (yyyy-MM-dd). Speeds up the lookup; otherwise recent weeks are searched.", + "type": [ + "string", + "null" + ] + }, + "timesheetId": { + "description": "Timesheet ID", + "type": "integer" + } + }, + "required": [ + "timesheetId" + ], + "type": "object" + }, + "name": "delete_timesheet" + }, + { + "description": "Get the current employee\u0027s billing rate for a client. Includes rate expiry info.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "clientId": { + "description": "Client ID", + "type": "string" + }, + "date": { + "default": null, + "description": "Date for rate lookup (yyyy-MM-dd). Defaults to today.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "clientId" + ], + "type": "object" + }, + "name": "get_client_rate" + }, + { + "description": "Get CRM bookings/appointments for a date range.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "endDate": { + "description": "End date (yyyy-MM-dd)", + "type": "string" + }, + "startDate": { + "description": "Start date (yyyy-MM-dd)", + "type": "string" + } + }, + "required": [ + "startDate", + "endDate" + ], + "type": "object" + }, + "name": "get_crm_bookings" + }, + { + "description": "Get leave stats for an employee: days since last leave and total leave hours taken in the last 12 months. Defaults to the current user\u0027s empId. (TimePro does not expose entitlement/remaining per leave type.)", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "empId": { + "default": null, + "description": "empId to read. Defaults to the current user\u0027s empId.", + "type": [ + "string", + "null" + ] + }, + "employeeId": { + "default": null, + "description": "Alias for empId.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "name": "get_leave_balance" + }, + { + "annotations": { + "destructiveHint": false, + "readOnlyHint": true + }, + "description": "Report when TimePro\u0027s leave balances were last imported from Xero, how many employees have a stored balance, and whether the data is stale. Read-only. Check this before importing so you can tell the user whether a re-import is actually needed.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "get_leave_balance_status" + }, + { + "description": "List EasyLeave entries. Use empId for one person; employeeId is accepted as an alias. Omit both to return all visible leave.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "empId": { + "default": null, + "description": "empId to filter by", + "type": [ + "string", + "null" + ] + }, + "employeeId": { + "default": null, + "description": "Alias for empId", + "type": [ + "string", + "null" + ] + }, + "filter": { + "default": "UPCOMING", + "description": "Filter: UPCOMING (default) or PAST", + "type": "string" + }, + "limit": { + "default": 10, + "description": "Number of entries to return", + "type": "integer" + } + }, + "type": "object" + }, + "name": "get_leave_entries" + }, + { + "description": "Get the WFH/location defaults and repo mapping for a given path.", + "inputSchema": { + "properties": { + "repoPath": { + "default": null, + "description": "Repository path to check for mapping", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "name": "get_location_and_mapping" + }, + { + "description": "Get projects for a client. Returns project IDs and names.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "clientId": { + "description": "Client ID", + "type": "string" + } + }, + "required": [ + "clientId" + ], + "type": "object" + }, + "name": "get_projects_for_client" + }, + { + "description": "Get suggested timesheets for a date. Refreshes suggestions first.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "date": { + "description": "Date (yyyy-MM-dd)", + "type": "string" + } + }, + "required": [ + "date" + ], + "type": "object" + }, + "name": "get_suggested_timesheets" + }, + { + "description": "Get timesheets for a date or date range. Use empId to read another employee\u0027s timesheets; employeeId is accepted as an alias.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "date": { + "description": "Single date or start date (yyyy-MM-dd)", + "type": "string" + }, + "empId": { + "default": null, + "description": "empId to read. Defaults to the current user\u0027s empId.", + "type": [ + "string", + "null" + ] + }, + "employeeId": { + "default": null, + "description": "Alias for empId.", + "type": [ + "string", + "null" + ] + }, + "endDate": { + "default": null, + "description": "End date for range (yyyy-MM-dd). If omitted, returns single day.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "date" + ], + "type": "object" + }, + "name": "get_timesheets" + }, + { + "description": "List iterations/sprints for a project. Returns empty list if the project doesn\u0027t use iterations. If the list is non-empty, an iteration ID is required when creating timesheets for this project.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "projectId": { + "description": "Project ID (e.g., 1I776Q)", + "type": "string" + } + }, + "required": [ + "projectId" + ], + "type": "object" + }, + "name": "list_iterations" + }, + { + "description": "Search for clients by name. Returns client IDs and names.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "query": { + "description": "Search text", + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search_clients" + }, + { + "description": "Update an existing leave request for the current user. Unspecified API-returned fields are preserved. List leave entries first to obtain the leave ID.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "approvedBy": { + "default": null, + "description": "New approver email; omit to preserve", + "type": [ + "string", + "null" + ] + }, + "cc": { + "default": null, + "description": "Comma-separated CC recipients; omit to preserve", + "type": [ + "string", + "null" + ] + }, + "clearApprovedBy": { + "default": false, + "description": "Remove the current approver", + "type": "boolean" + }, + "clearCc": { + "default": false, + "description": "Remove all current CC recipients", + "type": "boolean" + }, + "dryRun": { + "default": false, + "description": "Validate and return the proposed update without applying it", + "type": "boolean" + }, + "end": { + "default": null, + "description": "New end date (yyyy-MM-dd); omit to preserve", + "type": [ + "string", + "null" + ] + }, + "endTime": { + "default": null, + "description": "Employee workday end time (HH:mm); omit to preserve it when returned, otherwise use the profile value", + "type": [ + "string", + "null" + ] + }, + "halfDay": { + "default": null, + "description": "true for partial-day, false for full-day, omit to preserve", + "type": [ + "boolean", + "null" + ] + }, + "id": { + "description": "Leave entry ID (GUID)", + "type": "string" + }, + "note": { + "default": null, + "description": "New leave note/reason; omit to preserve", + "type": [ + "string", + "null" + ] + }, + "start": { + "default": null, + "description": "New start date (yyyy-MM-dd); omit to preserve", + "type": [ + "string", + "null" + ] + }, + "startTime": { + "default": null, + "description": "Employee workday start time (HH:mm); omit to preserve it when returned, otherwise use the profile value", + "type": [ + "string", + "null" + ] + }, + "timeZoneId": { + "default": null, + "description": "Timezone for changed dates (IANA or Windows ID)", + "type": [ + "string", + "null" + ] + }, + "type": { + "default": null, + "description": "New leave type ID or active leave type name; omit to preserve", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "name": "update_leave" + }, + { + "description": "Update an existing timesheet. Only specify fields you want to change; everything else is preserved. Returns the entry as saved.", + "execution": { + "taskSupport": "optional" + }, + "inputSchema": { + "properties": { + "billableId": { + "default": null, + "description": "New billable type: B, BPP, W", + "type": [ + "string", + "null" + ] + }, + "date": { + "default": null, + "description": "Date the timesheet is on (yyyy-MM-dd). Speeds up the lookup; otherwise recent weeks are searched.", + "type": [ + "string", + "null" + ] + }, + "description": { + "default": null, + "description": "New notes/description", + "type": [ + "string", + "null" + ] + }, + "iteration": { + "default": null, + "description": "New iteration/sprint, by name or ID. Use ListIterations to see the options.", + "type": [ + "string", + "null" + ] + }, + "location": { + "default": null, + "description": "New location", + "type": [ + "string", + "null" + ] + }, + "timesheetId": { + "description": "Timesheet ID", + "type": "integer" + } + }, + "required": [ + "timesheetId" + ], + "type": "object" + }, + "name": "update_timesheet" + } + ] +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/AcceptSuggestedTimesheet.1.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/AcceptSuggestedTimesheet.1.cli.json new file mode 100644 index 0000000..b64f25b --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/AcceptSuggestedTimesheet.1.cli.json @@ -0,0 +1,30 @@ +{ + "iterationApplied": true, + "success": true, + "timesheet": { + "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 + }, + "timesheetId": 4242 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/AcceptSuggestedTimesheet.1.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/AcceptSuggestedTimesheet.1.mcp.json new file mode 100644 index 0000000..3672d1d --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/AcceptSuggestedTimesheet.1.mcp.json @@ -0,0 +1,36 @@ +{ + "iterationApplied": true, + "message": null, + "success": true, + "timesheet": { + "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, + "inputSource": null, + "invoiceId": null, + "invoiceType": null, + "isBillable": true, + "isLeave": false, + "isLocked": false, + "isSuggested": false, + "iteration": "Checkout API", + "iterationId": null, + "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 + }, + "timesheetId": 4242, + "warning": null +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CheckWeek.4.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CheckWeek.4.cli.json new file mode 100644 index 0000000..831a21c --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CheckWeek.4.cli.json @@ -0,0 +1,96 @@ +{ + "allCovered": false, + "days": [ + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday0}}", + "dayOfWeek": "Monday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + }, + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday1}}", + "dayOfWeek": "Tuesday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + }, + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday2}}", + "dayOfWeek": "Wednesday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + }, + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday3}}", + "dayOfWeek": "Thursday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + }, + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday4}}", + "dayOfWeek": "Friday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + } + ], + "empId": "BOB", + "errors": 0, + "infos": 5, + "warnings": 0, + "weekEnd": "{{weekday4}}", + "weekStart": "{{weekday0}}" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CheckWeek.4.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CheckWeek.4.mcp.json new file mode 100644 index 0000000..831a21c --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CheckWeek.4.mcp.json @@ -0,0 +1,96 @@ +{ + "allCovered": false, + "days": [ + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday0}}", + "dayOfWeek": "Monday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + }, + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday1}}", + "dayOfWeek": "Tuesday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + }, + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday2}}", + "dayOfWeek": "Wednesday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + }, + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday3}}", + "dayOfWeek": "Thursday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + }, + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday4}}", + "dayOfWeek": "Friday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + } + ], + "empId": "BOB", + "errors": 0, + "infos": 5, + "warnings": 0, + "weekEnd": "{{weekday4}}", + "weekStart": "{{weekday0}}" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateLeave.5.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateLeave.5.cli.json new file mode 100644 index 0000000..9f6598a --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateLeave.5.cli.json @@ -0,0 +1,17 @@ +{ + "dryRun": true, + "request": { + "allDay": true, + "approvedBy": "bob@northwind.example", + "endDate": "2026-04-01T23:59:00.0000000\u002B10:00", + "leaveTypeId": 1, + "note": "Family day", + "optionalEmp": [ + "bob@northwind.example" + ], + "requestedEmpId": "BOB", + "startDate": "2026-04-01T00:00:00.0000000\u002B10:00", + "userEndTime": "18:00:00", + "userStartTime": "09:00:00" + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateLeave.5.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateLeave.5.mcp.json new file mode 100644 index 0000000..6665781 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateLeave.5.mcp.json @@ -0,0 +1,18 @@ +{ + "dryRun": true, + "request": { + "allDay": true, + "approvedBy": "bob@northwind.example", + "endDate": "2026-04-01T23:59:00.0000000\u002B10:00", + "leaveTypeId": 1, + "note": "Family day", + "optionalEmp": [ + "bob@northwind.example" + ], + "requestedEmpId": "BOB", + "startDate": "2026-04-01T00:00:00.0000000\u002B10:00", + "timeLessOverride": null, + "userEndTime": "18:00:00", + "userStartTime": "09:00:00" + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.2.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.2.cli.json new file mode 100644 index 0000000..c28d30a --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.2.cli.json @@ -0,0 +1,4 @@ +{ + "success": true, + "timesheetId": 4242 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.2.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.2.mcp.json new file mode 100644 index 0000000..c28d30a --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.2.mcp.json @@ -0,0 +1,4 @@ +{ + "success": true, + "timesheetId": 4242 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.3.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.3.cli.json new file mode 100644 index 0000000..f90a31b --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.3.cli.json @@ -0,0 +1,7 @@ +{ + "error": { + "code": null, + "detail": null, + "message": "Timesheet 4243 is a suggestion and cannot be deleted. Accept it first: tp ts accept 4243" + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.3.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.3.mcp.json new file mode 100644 index 0000000..f2681dc --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/DeleteTimesheet.3.mcp.json @@ -0,0 +1,3 @@ +{ + "error": "Timesheet 4243 is a suggestion and cannot be deleted. Accept it first: tp ts accept 4243" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetLeaveBalanceStatus.8.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetLeaveBalanceStatus.8.cli.json new file mode 100644 index 0000000..c08d93d --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetLeaveBalanceStatus.8.cli.json @@ -0,0 +1,6 @@ +{ + "asAtDate": "2026-03-01", + "employeeCount": 12, + "isStale": false, + "lastImportedAt": "2026-03-02T04:30:00\u002B00:00" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetLeaveBalanceStatus.8.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetLeaveBalanceStatus.8.mcp.json new file mode 100644 index 0000000..c08d93d --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetLeaveBalanceStatus.8.mcp.json @@ -0,0 +1,6 @@ +{ + "asAtDate": "2026-03-01", + "employeeCount": 12, + "isStale": false, + "lastImportedAt": "2026-03-02T04:30:00\u002B00:00" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/ImportLeaveBalances.7.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/ImportLeaveBalances.7.cli.json new file mode 100644 index 0000000..b8d02bc --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/ImportLeaveBalances.7.cli.json @@ -0,0 +1,11 @@ +{ + "asAtDate": "2026-03-01", + "created": 1, + "unmatchedEmployees": [ + "Bobby Northwind" + ], + "updated": 2, + "warnings": [ + "Implausible balance for BOB: 999 days" + ] +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/ImportLeaveBalances.7.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/ImportLeaveBalances.7.mcp.json new file mode 100644 index 0000000..0eb7328 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/ImportLeaveBalances.7.mcp.json @@ -0,0 +1,12 @@ +{ + "asAtDate": "2026-03-01", + "created": 1, + "success": true, + "unmatchedEmployees": [ + "Bobby Northwind" + ], + "updated": 2, + "warnings": [ + "Implausible balance for BOB: 999 days" + ] +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateLeave.6.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateLeave.6.cli.json new file mode 100644 index 0000000..f79c8a9 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateLeave.6.cli.json @@ -0,0 +1,22 @@ +{ + "changes": [ + "Note -\u003E Family day (updated)" + ], + "dryRun": true, + "leaveId": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa", + "request": { + "allDay": true, + "approvedBy": "bob@northwind.example", + "endDate": "2026-04-01T23:59:00.0000000\u002B10:00", + "id": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa", + "leaveTypeId": 1, + "note": "Family day (updated)", + "optionalEmp": [ + "bob@northwind.example" + ], + "requestedEmpId": "BOB", + "startDate": "2026-04-01T00:00:00.0000000\u002B10:00", + "userEndTime": "18:00:00", + "userStartTime": "09:00:00" + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateLeave.6.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateLeave.6.mcp.json new file mode 100644 index 0000000..89425f4 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateLeave.6.mcp.json @@ -0,0 +1,23 @@ +{ + "changes": [ + "Note -\u003E Family day (updated)" + ], + "dryRun": true, + "leaveId": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa", + "request": { + "allDay": true, + "approvedBy": "bob@northwind.example", + "endDate": "2026-04-01T23:59:00.0000000\u002B10:00", + "id": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa", + "leaveTypeId": 1, + "note": "Family day (updated)", + "optionalEmp": [ + "bob@northwind.example" + ], + "requestedEmpId": "BOB", + "startDate": "2026-04-01T00:00:00.0000000\u002B10:00", + "timeLessOverride": null, + "userEndTime": "18:00:00", + "userStartTime": "09:00:00" + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateTimesheet.0.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateTimesheet.0.cli.json new file mode 100644 index 0000000..ad9d3a3 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateTimesheet.0.cli.json @@ -0,0 +1,29 @@ +{ + "success": true, + "timesheet": { + "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 + }, + "timesheetId": 4242 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateTimesheet.0.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateTimesheet.0.mcp.json new file mode 100644 index 0000000..a35361c --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/UpdateTimesheet.0.mcp.json @@ -0,0 +1,36 @@ +{ + "iterationApplied": null, + "message": null, + "success": true, + "timesheet": { + "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, + "inputSource": null, + "invoiceId": null, + "invoiceType": null, + "isBillable": true, + "isLeave": false, + "isLocked": false, + "isSuggested": false, + "iteration": "Checkout API", + "iterationId": null, + "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 + }, + "timesheetId": 4242, + "warning": null +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/AcceptSuggestedTimesheet.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/AcceptSuggestedTimesheet.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/AcceptSuggestedTimesheet.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/AcceptSuggestedTimesheet.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/AcceptSuggestedTimesheet.populated.json new file mode 100644 index 0000000..3672d1d --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/AcceptSuggestedTimesheet.populated.json @@ -0,0 +1,36 @@ +{ + "iterationApplied": true, + "message": null, + "success": true, + "timesheet": { + "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, + "inputSource": null, + "invoiceId": null, + "invoiceType": null, + "isBillable": true, + "isLeave": false, + "isLocked": false, + "isSuggested": false, + "iteration": "Checkout API", + "iterationId": null, + "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 + }, + "timesheetId": 4242, + "warning": null +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CheckWeek.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CheckWeek.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CheckWeek.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CheckWeek.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CheckWeek.populated.json new file mode 100644 index 0000000..831a21c --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CheckWeek.populated.json @@ -0,0 +1,96 @@ +{ + "allCovered": false, + "days": [ + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday0}}", + "dayOfWeek": "Monday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + }, + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday1}}", + "dayOfWeek": "Tuesday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + }, + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday2}}", + "dayOfWeek": "Wednesday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + }, + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday3}}", + "dayOfWeek": "Thursday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + }, + { + "coverReason": "logged", + "covered": false, + "date": "{{weekday4}}", + "dayOfWeek": "Friday", + "issues": [ + { + "message": "1 suggested timesheet(s) not accepted", + "severity": "info" + } + ], + "leaveHours": 0, + "leaveType": null, + "suggestedCount": 1, + "timesheetCount": 1, + "totalHours": 7.5 + } + ], + "empId": "BOB", + "errors": 0, + "infos": 5, + "warnings": 0, + "weekEnd": "{{weekday4}}", + "weekStart": "{{weekday0}}" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateLeave.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateLeave.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateLeave.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateLeave.dryRun.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateLeave.dryRun.json new file mode 100644 index 0000000..6665781 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateLeave.dryRun.json @@ -0,0 +1,18 @@ +{ + "dryRun": true, + "request": { + "allDay": true, + "approvedBy": "bob@northwind.example", + "endDate": "2026-04-01T23:59:00.0000000\u002B10:00", + "leaveTypeId": 1, + "note": "Family day", + "optionalEmp": [ + "bob@northwind.example" + ], + "requestedEmpId": "BOB", + "startDate": "2026-04-01T00:00:00.0000000\u002B10:00", + "timeLessOverride": null, + "userEndTime": "18:00:00", + "userStartTime": "09:00:00" + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.populated.json new file mode 100644 index 0000000..19765bd --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.populated.json @@ -0,0 +1 @@ +null diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/DeleteTimesheet.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/DeleteTimesheet.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/DeleteTimesheet.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/DeleteTimesheet.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/DeleteTimesheet.populated.json new file mode 100644 index 0000000..c28d30a --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/DeleteTimesheet.populated.json @@ -0,0 +1,4 @@ +{ + "success": true, + "timesheetId": 4242 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientOutstanding.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientOutstanding.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientOutstanding.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientOutstanding.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientOutstanding.empty.json new file mode 100644 index 0000000..19765bd --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientOutstanding.empty.json @@ -0,0 +1 @@ +null diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientOutstanding.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientOutstanding.populated.json new file mode 100644 index 0000000..cf45fad --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientOutstanding.populated.json @@ -0,0 +1,17 @@ +{ + "clientId": "NWIND", + "coName": "Northwind Traders", + "contactPerson": "Bob Northwind", + "outstandingInvoices": [ + { + "dateInvoiced": "2026-03-31T00:00:00", + "daysOverdue": 7, + "dueDate": "2026-04-14T00:00:00", + "invoiceId": 108, + "invoiceType": "P", + "osAmt": 1100, + "paidAmt": 0, + "total": 1100 + } + ] +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientRate.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientRate.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientRate.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientRate.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientRate.empty.json new file mode 100644 index 0000000..19765bd --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientRate.empty.json @@ -0,0 +1 @@ +null diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientRate.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientRate.populated.json new file mode 100644 index 0000000..0e0cf33 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientRate.populated.json @@ -0,0 +1,11 @@ +{ + "clientId": "NWIND", + "clientName": "Northwind Traders", + "clientRateId": 900, + "empId": "BOB", + "employeeName": "Bob Northwind", + "expiryDate": "2026-12-31", + "notes": "Standard rate", + "prepaidRate": 150, + "rate": 175 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientsWithOutstandingTime.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientsWithOutstandingTime.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientsWithOutstandingTime.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientsWithOutstandingTime.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientsWithOutstandingTime.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientsWithOutstandingTime.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientsWithOutstandingTime.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientsWithOutstandingTime.populated.json new file mode 100644 index 0000000..519d361 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetClientsWithOutstandingTime.populated.json @@ -0,0 +1,15 @@ +[ + { + "billable": 600, + "clientId": "NWIND", + "coName": "Northwind Traders", + "dateUpdated": "2026-03-16T00:00:00", + "earliestUnAllocatedTimesheetDate": "2026-03-16T00:00:00", + "empId": "BOB", + "firstName": "Bob", + "os": 600, + "state": "QLD", + "suburb": "Brisbane", + "surname": "Northwind" + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCrmBookings.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCrmBookings.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCrmBookings.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCrmBookings.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCrmBookings.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCrmBookings.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCrmBookings.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCrmBookings.populated.json new file mode 100644 index 0000000..ca9ea2a --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCrmBookings.populated.json @@ -0,0 +1,14 @@ +[ + { + "allDay": false, + "clientId": "NWIND", + "editable": true, + "end": "2026-03-16T10:00:00", + "id": "appt-1", + "iterationId": 3402, + "projectId": "1I776Q", + "start": "2026-03-16T09:00:00", + "timeZoneOffsetInMinutes": 600, + "title": "Northwind Traders - Checkout API review" + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCurrentUser.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCurrentUser.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCurrentUser.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCurrentUser.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCurrentUser.empty.json new file mode 100644 index 0000000..19765bd --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCurrentUser.empty.json @@ -0,0 +1 @@ +null diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCurrentUser.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCurrentUser.populated.json new file mode 100644 index 0000000..13ad775 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetCurrentUser.populated.json @@ -0,0 +1,7 @@ +{ + "defaultRate": "160", + "email": "bob@northwind.example", + "firstName": "Bob", + "id": "BOB", + "surname": "Northwind" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoice.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoice.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoice.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoice.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoice.empty.json new file mode 100644 index 0000000..19765bd --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoice.empty.json @@ -0,0 +1 @@ +null diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoice.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoice.populated.json new file mode 100644 index 0000000..7a17d54 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoice.populated.json @@ -0,0 +1,30 @@ +{ + "batch": 1, + "categoryId": "WEBDEV", + "clientId": "NWIND", + "clientRef": "PO-42", + "currencyId": "AUD", + "dateCreated": "2026-03-31T00:00:00", + "dateEnd": "2026-03-31T00:00:00", + "dateStart": "2026-03-01T00:00:00", + "dateUpdated": "2026-03-31T00:00:00", + "exchangeRate": 1, + "externalSyncStatus": 0, + "invoiceId": 108, + "invoiceType": "P", + "isCreditNote": false, + "isLocked": false, + "isRecurring": false, + "month": "2026-03", + "note": "Prepaid block", + "osAmt": 1100, + "outstandingDays": 7, + "paidAmt": 0, + "paymentTerms": 14, + "projectId": "1I776Q", + "remainingPrepaidCredit": 400, + "salesTaxAmt": 100, + "salesTaxPct": 10, + "sellTotal": 1100, + "subTotal": 1000 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceLines.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceLines.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceLines.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceLines.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceLines.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceLines.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceLines.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceLines.populated.json new file mode 100644 index 0000000..e8434ed --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceLines.populated.json @@ -0,0 +1,17 @@ +[ + { + "categoryId": "WEBDEV", + "empId": "BOB", + "invoiceId": 108, + "invoiceProdId": 1, + "prodName": "Prepaid block", + "qty": 1, + "salesTaxAmt": 100, + "salesTaxPct": 10, + "sellAmt": 1000, + "sellTotal": 1000, + "skuId": "PREPAID-10", + "skuName": "Prepaid 10 hours", + "type": "S" + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceReceipts.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceReceipts.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceReceipts.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceReceipts.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceReceipts.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceReceipts.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceReceipts.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceReceipts.populated.json new file mode 100644 index 0000000..7969fd0 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceReceipts.populated.json @@ -0,0 +1,13 @@ +[ + { + "coName": "Northwind Traders", + "dateCreated": "2026-04-01T00:00:00", + "empUpdated": "BOB", + "invoiceId": 108, + "note": "EFT", + "paid": 1100, + "paymentDate": "2026-04-01T00:00:00", + "saleReceiptId": 142, + "saleReceiptStatus": "Allocated" + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceTimesheets.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceTimesheets.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceTimesheets.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceTimesheets.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceTimesheets.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceTimesheets.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceTimesheets.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceTimesheets.populated.json new file mode 100644 index 0000000..cf3cd7d --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoiceTimesheets.populated.json @@ -0,0 +1,25 @@ +[ + { + "amount": 600, + "billableAmount": 600, + "billableId": "BPP", + "categoryId": "WEBDEV", + "clientId": "NWIND", + "clientName": "Northwind Traders", + "dateCreated": "2026-03-16T00:00:00", + "empId": "BOB", + "empName": "Bob Northwind", + "invoiceId": 108, + "notes": "Checkout API", + "projectId": "1I776Q", + "projectName": "Northwind Traders", + "salesTaxAmt": 60, + "salesTaxPct": 10, + "sellPrice": 80, + "sellTotal": 600, + "timeEnd": "17:00", + "timeId": 4242, + "timeStart": "09:00", + "totalTime": 7.5 + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoicesByClient.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoicesByClient.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoicesByClient.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoicesByClient.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoicesByClient.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoicesByClient.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoicesByClient.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoicesByClient.populated.json new file mode 100644 index 0000000..e041667 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetInvoicesByClient.populated.json @@ -0,0 +1,32 @@ +[ + { + "batch": 1, + "categoryId": "WEBDEV", + "clientId": "NWIND", + "clientRef": "PO-42", + "currencyId": "AUD", + "dateCreated": "2026-03-31T00:00:00", + "dateEnd": "2026-03-31T00:00:00", + "dateStart": "2026-03-01T00:00:00", + "dateUpdated": "2026-03-31T00:00:00", + "exchangeRate": 1, + "externalSyncStatus": 0, + "invoiceId": 108, + "invoiceType": "P", + "isCreditNote": false, + "isLocked": false, + "isRecurring": false, + "month": "2026-03", + "note": "Prepaid block", + "osAmt": 1100, + "outstandingDays": 7, + "paidAmt": 0, + "paymentTerms": 14, + "projectId": "1I776Q", + "remainingPrepaidCredit": 400, + "salesTaxAmt": 100, + "salesTaxPct": 10, + "sellTotal": 1100, + "subTotal": 1000 + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalance.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalance.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalance.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalance.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalance.empty.json new file mode 100644 index 0000000..23f77c9 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalance.empty.json @@ -0,0 +1,3 @@ +{ + "error": "No leave stats found for BOB." +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalance.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalance.populated.json new file mode 100644 index 0000000..7f0ecb7 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalance.populated.json @@ -0,0 +1,5 @@ +{ + "daysSinceLastLeave": 42, + "empId": "BOB", + "leaveTakenInLast12Months": 0 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalanceStatus.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalanceStatus.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalanceStatus.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalanceStatus.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalanceStatus.empty.json new file mode 100644 index 0000000..10e3d3e --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalanceStatus.empty.json @@ -0,0 +1,3 @@ +{ + "imported": false +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalanceStatus.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalanceStatus.populated.json new file mode 100644 index 0000000..c08d93d --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveBalanceStatus.populated.json @@ -0,0 +1,6 @@ +{ + "asAtDate": "2026-03-01", + "employeeCount": 12, + "isStale": false, + "lastImportedAt": "2026-03-02T04:30:00\u002B00:00" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveEntries.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveEntries.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveEntries.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveEntries.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveEntries.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveEntries.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveEntries.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveEntries.populated.json new file mode 100644 index 0000000..72f88b9 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLeaveEntries.populated.json @@ -0,0 +1,31 @@ +[ + { + "allDay": true, + "approvedBy": "bob@northwind.example", + "cancellationReason": null, + "createdAt": "2026-03-01T09:00:00\u002B10:00", + "daysAway": 1, + "endDate": "2026-04-01T23:59:00\u002B10:00", + "endDateWithoutOffset": null, + "id": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa", + "leaveType": { + "id": 1, + "isActive": true, + "name": "Annual Leave" + }, + "length": 1, + "note": "Family day", + "optionalEmp": [ + "bob@northwind.example" + ], + "requestedEmpId": "BOB", + "startDate": "2026-04-01T00:00:00\u002B10:00", + "startDateWithoutOffset": null, + "status": 1, + "statusName": "Pending", + "timeLessOverride": null, + "updatedAt": "2026-03-02T09:00:00\u002B10:00", + "userEndTime": "18:00", + "userStartTime": "09:00" + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLocationAndMapping.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLocationAndMapping.populated.json new file mode 100644 index 0000000..abd4040 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetLocationAndMapping.populated.json @@ -0,0 +1,10 @@ +{ + "defaultLocation": "Office", + "repoMapping": { + "categoryId": "WEBDEV", + "clientId": "NWIND", + "projectId": "1I776Q", + "projectName": "Northwind Traders" + }, + "wfhDays": [] +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatus.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatus.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatus.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatus.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatus.empty.json new file mode 100644 index 0000000..dc2fcdf --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatus.empty.json @@ -0,0 +1,3 @@ +{ + "error": "Invoice 108 not found." +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatus.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatus.populated.json new file mode 100644 index 0000000..1e3bda6 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatus.populated.json @@ -0,0 +1,33 @@ +{ + "clientId": "NWIND", + "credited": { + "exGst": 100, + "gst": 10, + "incGst": 110 + }, + "creditingCreditNoteCount": 1, + "currencyId": "AUD", + "drawdownTimesheetCount": 1, + "drawnDown": { + "exGst": 600, + "gst": 60, + "incGst": 660 + }, + "exchangeRate": 1, + "invoiceId": 108, + "invoiceType": "P", + "original": { + "exGst": 1000, + "gst": 100, + "incGst": 1100 + }, + "reconciliationDeltaExGst": -100, + "reconciliationDeltaIncGst": -100, + "remaining": { + "exGst": 400, + "gst": 30, + "incGst": 430 + }, + "remainingExGstSource": "api/ClientInvoice/GetByClientId/{clientId}.remainingPrepaidCredit", + "salesTaxPct": 10 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatusPdf.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatusPdf.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatusPdf.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatusPdf.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatusPdf.populated.json new file mode 100644 index 0000000..20d60cc --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetPrepaidStatusPdf.populated.json @@ -0,0 +1,7 @@ +{ + "contentBase64": "JVBERi0xLjQgbm9ydGh3aW5k", + "contentType": "application/pdf", + "invoiceId": 108, + "sizeBytes": 18, + "templateId": 0 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProduct.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProduct.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProduct.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProduct.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProduct.empty.json new file mode 100644 index 0000000..19765bd --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProduct.empty.json @@ -0,0 +1 @@ +null diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProduct.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProduct.populated.json new file mode 100644 index 0000000..dea4799 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProduct.populated.json @@ -0,0 +1,25 @@ +{ + "allowDiscount": true, + "allowDiscount2": false, + "dateCreated": "2026-01-01T00:00:00", + "dateUpdated": "2026-01-01T00:00:00", + "displayOnWeb": false, + "head": "Prepaid", + "isTraining": false, + "productId": "PREPAID", + "productName": "Prepaid block", + "skus": [ + { + "costAmt": 0, + "dateCreated": "2026-01-01T00:00:00", + "dateUpdated": "2026-01-01T00:00:00", + "displayOnWeb": false, + "isPrepaid": true, + "productId": "PREPAID", + "rrpAmt": 1000, + "sellAmt": 1000, + "skuId": "PREPAID-10", + "skuName": "Prepaid 10 hours" + } + ] +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProductDiscountsForClient.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProductDiscountsForClient.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProductDiscountsForClient.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProductDiscountsForClient.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProductDiscountsForClient.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProductDiscountsForClient.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProductDiscountsForClient.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProductDiscountsForClient.populated.json new file mode 100644 index 0000000..cb80505 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProductDiscountsForClient.populated.json @@ -0,0 +1,11 @@ +[ + { + "clientId": "NWIND", + "dateEnd": "2026-12-31T00:00:00", + "dateStart": "2026-01-01T00:00:00", + "discountPct": 5, + "note": "Volume discount", + "productId": "PREPAID", + "skuId": "PREPAID-10" + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsForClient.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsForClient.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsForClient.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsForClient.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsForClient.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsForClient.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsForClient.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsForClient.populated.json new file mode 100644 index 0000000..33e62c4 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsForClient.populated.json @@ -0,0 +1,16 @@ +[ + { + "displayText": "Northwind Traders", + "isGeneral": false, + "isLeave": false, + "useIteration": true, + "value": "1I776Q" + }, + { + "displayText": "Upgrade - Northwind", + "isGeneral": false, + "isLeave": false, + "useIteration": false, + "value": "N8000J" + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsSummary.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsSummary.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsSummary.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsSummary.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsSummary.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsSummary.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsSummary.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsSummary.populated.json new file mode 100644 index 0000000..e94fb1d --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetProjectsSummary.populated.json @@ -0,0 +1,11 @@ +[ + { + "clientID": "NWIND", + "endDate": "2026-03-31", + "isBillable": true, + "projectID": "1I776Q", + "projectName": "Northwind Traders", + "startDate": "2026-03-01", + "sum": 22.5 + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetReceiptDetail.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetReceiptDetail.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetReceiptDetail.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetReceiptDetail.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetReceiptDetail.empty.json new file mode 100644 index 0000000..19765bd --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetReceiptDetail.empty.json @@ -0,0 +1 @@ +null diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetReceiptDetail.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetReceiptDetail.populated.json new file mode 100644 index 0000000..21da55d --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetReceiptDetail.populated.json @@ -0,0 +1,38 @@ +{ + "contactPerson": "Bob Northwind", + "invoicesAmount": 1100, + "paymentMethods": [ + { + "id": "EFT", + "name": "EFT", + "sign": "-" + } + ], + "receipt": { + "clientId": "NWIND", + "coName": "Northwind Traders", + "isNew": false, + "note": "EFT", + "outstandingInvoices": [], + "paymentDate": "2026-04-01T00:00:00", + "receiptTotal": 1100, + "receiptType": "EFT", + "saleReceiptId": 142, + "saleReceiptPaids": [ + { + "alreadyPaidAmt": 0, + "balance": 0, + "clientId": "NWIND", + "coName": "Northwind Traders", + "invoiceDate": "2026-03-31T00:00:00", + "invoiceId": 108, + "isAllocated": true, + "paidAmt": 1100, + "paymentDate": "2026-04-01T00:00:00", + "saleReceiptId": 142, + "total": 1100 + } + ] + }, + "totalPaid": 1100 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetRecurringInvoice.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetRecurringInvoice.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetRecurringInvoice.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetRecurringInvoice.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetRecurringInvoice.empty.json new file mode 100644 index 0000000..19765bd --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetRecurringInvoice.empty.json @@ -0,0 +1 @@ +null diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetRecurringInvoice.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetRecurringInvoice.populated.json new file mode 100644 index 0000000..67f4672 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetRecurringInvoice.populated.json @@ -0,0 +1,36 @@ +{ + "canGenerateNow": true, + "clientId": "NWIND", + "countOfInv": 3, + "createdBy": "BOB", + "createdOn": "2026-01-01T00:00:00", + "dateEnd": "2026-12-31T00:00:00", + "dateStart": "2026-01-01T00:00:00", + "firstInvPeriod": 1, + "id": 42, + "lastInvEndDate": "2026-03-31T00:00:00", + "lastInvPeriod": 1, + "nextInvoicePeriodEnd": "2026-04-30T00:00:00", + "nextInvoicePeriodStart": "2026-04-01T00:00:00", + "note": "Monthly retainer", + "products": [ + { + "createdBy": "BOB", + "createdOn": "2026-01-01T00:00:00", + "id": 1, + "prodId": "PREPAID", + "prodName": "Prepaid block", + "qty": 1, + "recurringInvoiceId": 42, + "salesTaxAmt": 100, + "salesTaxPct": 10, + "sellAmt": 1000, + "sellTotal": 1000 + } + ], + "sellAmt": 1000, + "sellTaxAmt": 100, + "sellTotal": 1100, + "subsequentInvPeriod": 1, + "unit": "month" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetSuggestedTimesheets.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetSuggestedTimesheets.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetSuggestedTimesheets.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetSuggestedTimesheets.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetSuggestedTimesheets.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetSuggestedTimesheets.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetSuggestedTimesheets.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetSuggestedTimesheets.populated.json new file mode 100644 index 0000000..07f3eee --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetSuggestedTimesheets.populated.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/Tools/GetTimesheets.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.populated.json new file mode 100644 index 0000000..19520db --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetTimesheets.populated.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/Tools/GetUnbilledTimesheetsForClient.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnbilledTimesheetsForClient.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnbilledTimesheetsForClient.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnbilledTimesheetsForClient.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnbilledTimesheetsForClient.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnbilledTimesheetsForClient.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnbilledTimesheetsForClient.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnbilledTimesheetsForClient.populated.json new file mode 100644 index 0000000..defbb39 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnbilledTimesheetsForClient.populated.json @@ -0,0 +1,25 @@ +[ + { + "amount": 600, + "billableAmount": 600, + "billableId": "B", + "categoryId": "WEBDEV", + "clientId": "NWIND", + "clientName": "Northwind Traders", + "dateCreated": "2026-03-16T00:00:00", + "empId": "BOB", + "empName": "Bob Northwind", + "invoiceId": 108, + "notes": "Checkout API", + "projectId": "1I776Q", + "projectName": "Northwind Traders", + "salesTaxAmt": 60, + "salesTaxPct": 10, + "sellPrice": 80, + "sellTotal": 600, + "timeEnd": "17:00", + "timeId": 4242, + "timeStart": "09:00", + "totalTime": 7.5 + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnpaidInvoicesByClient.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnpaidInvoicesByClient.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnpaidInvoicesByClient.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnpaidInvoicesByClient.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnpaidInvoicesByClient.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnpaidInvoicesByClient.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnpaidInvoicesByClient.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnpaidInvoicesByClient.populated.json new file mode 100644 index 0000000..e041667 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/GetUnpaidInvoicesByClient.populated.json @@ -0,0 +1,32 @@ +[ + { + "batch": 1, + "categoryId": "WEBDEV", + "clientId": "NWIND", + "clientRef": "PO-42", + "currencyId": "AUD", + "dateCreated": "2026-03-31T00:00:00", + "dateEnd": "2026-03-31T00:00:00", + "dateStart": "2026-03-01T00:00:00", + "dateUpdated": "2026-03-31T00:00:00", + "exchangeRate": 1, + "externalSyncStatus": 0, + "invoiceId": 108, + "invoiceType": "P", + "isCreditNote": false, + "isLocked": false, + "isRecurring": false, + "month": "2026-03", + "note": "Prepaid block", + "osAmt": 1100, + "outstandingDays": 7, + "paidAmt": 0, + "paymentTerms": 14, + "projectId": "1I776Q", + "remainingPrepaidCredit": 400, + "salesTaxAmt": 100, + "salesTaxPct": 10, + "sellTotal": 1100, + "subTotal": 1000 + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ImportLeaveBalances.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ImportLeaveBalances.apiError.json new file mode 100644 index 0000000..c5461ce --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ImportLeaveBalances.apiError.json @@ -0,0 +1,4 @@ +{ + "error": "The import request was sent and may have been applied, but TimePro did not return a complete result. Run \u0027tp leave balances status\u0027 before retrying.", + "mayHaveBeenApplied": true +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ImportLeaveBalances.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ImportLeaveBalances.populated.json new file mode 100644 index 0000000..0eb7328 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ImportLeaveBalances.populated.json @@ -0,0 +1,12 @@ +{ + "asAtDate": "2026-03-01", + "created": 1, + "success": true, + "unmatchedEmployees": [ + "Bobby Northwind" + ], + "updated": 2, + "warnings": [ + "Implausible balance for BOB: 999 days" + ] +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListAllSkus.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListAllSkus.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListAllSkus.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListAllSkus.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListAllSkus.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListAllSkus.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListAllSkus.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListAllSkus.populated.json new file mode 100644 index 0000000..c471fec --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListAllSkus.populated.json @@ -0,0 +1,14 @@ +[ + { + "costAmt": 0, + "dateCreated": "2026-01-01T00:00:00", + "dateUpdated": "2026-01-01T00:00:00", + "displayOnWeb": false, + "isPrepaid": true, + "productId": "PREPAID", + "rrpAmt": 1000, + "sellAmt": 1000, + "skuId": "PREPAID-10", + "skuName": "Prepaid 10 hours" + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListBillableTypes.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListBillableTypes.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListBillableTypes.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListBillableTypes.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListBillableTypes.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListBillableTypes.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListBillableTypes.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListBillableTypes.populated.json new file mode 100644 index 0000000..529647f --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListBillableTypes.populated.json @@ -0,0 +1,14 @@ +[ + { + "text": "Billable", + "value": "B" + }, + { + "text": "Prepaid", + "value": "BPP" + }, + { + "text": "Write-off", + "value": "W" + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCategories.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCategories.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCategories.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCategories.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCategories.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCategories.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCategories.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCategories.populated.json new file mode 100644 index 0000000..0b5d256 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCategories.populated.json @@ -0,0 +1,12 @@ +[ + { + "categoryId": "WEBDEV", + "categoryName": "Web development", + "isNonWorking": false + }, + { + "categoryId": "TRAIN", + "categoryName": "Training", + "isNonWorking": false + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListClientRates.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListClientRates.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListClientRates.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListClientRates.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListClientRates.empty.json new file mode 100644 index 0000000..cd7bbf0 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListClientRates.empty.json @@ -0,0 +1,4 @@ +{ + "rates": [], + "total": 0 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListClientRates.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListClientRates.populated.json new file mode 100644 index 0000000..0d432db --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListClientRates.populated.json @@ -0,0 +1,18 @@ +{ + "rates": [ + { + "clientId": "NWIND", + "clientName": "Northwind Traders", + "clientRateId": 900, + "dateCreated": "2026-01-01T00:00:00", + "dateUpdated": "2026-01-01T00:00:00", + "empId": "BOB", + "employeeName": "Bob Northwind", + "expiryDate": "2026-12-31T00:00:00", + "notes": "Standard rate", + "prepaidRate": 150, + "rate": 175 + } + ], + "total": 1 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCreditNotes.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCreditNotes.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCreditNotes.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCreditNotes.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCreditNotes.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCreditNotes.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCreditNotes.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCreditNotes.populated.json new file mode 100644 index 0000000..bcb7fda --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListCreditNotes.populated.json @@ -0,0 +1,15 @@ +[ + { + "amount": 110, + "associatedInvoiceId": 108, + "creditNoteDate": "2026-04-02T00:00:00", + "id": 7, + "isCreditingInvoice": true, + "isLocked": false, + "note": "Goodwill credit", + "paid": 0, + "syncDisplayName": "Not synced", + "syncStatus": 0, + "taxRate": 10 + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListInvoices.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListInvoices.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListInvoices.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListInvoices.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListInvoices.empty.json new file mode 100644 index 0000000..e88e669 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListInvoices.empty.json @@ -0,0 +1,4 @@ +{ + "data": [], + "total": 0 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListInvoices.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListInvoices.populated.json new file mode 100644 index 0000000..5130c8f --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListInvoices.populated.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "clientId": "NWIND", + "coName": "Northwind Traders", + "dateCreated": "2026-03-31T00:00:00", + "externalSyncStatus": 0, + "invoiceId": 108, + "invoiceType": "P", + "paidAmt": 0, + "sellTotal": 1100 + } + ], + "total": 1 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListIterations.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListIterations.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListIterations.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListIterations.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListIterations.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListIterations.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListIterations.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListIterations.populated.json new file mode 100644 index 0000000..aa2150c --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListIterations.populated.json @@ -0,0 +1,12 @@ +[ + { + "iterationId": 3402, + "iterationName": "Checkout API", + "lastUsedDate": "2026-03-09" + }, + { + "iterationId": 3403, + "iterationName": "Order history", + "lastUsedDate": "2026-03-13" + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListLocations.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListLocations.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListLocations.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListLocations.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListLocations.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListLocations.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListLocations.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListLocations.populated.json new file mode 100644 index 0000000..0fceb11 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListLocations.populated.json @@ -0,0 +1,10 @@ +[ + { + "locationId": "SSW", + "locationName": "SSW" + }, + { + "locationId": "Home", + "locationName": "Home" + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListPaidReceipts.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListPaidReceipts.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListPaidReceipts.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListPaidReceipts.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListPaidReceipts.empty.json new file mode 100644 index 0000000..e88e669 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListPaidReceipts.empty.json @@ -0,0 +1,4 @@ +{ + "data": [], + "total": 0 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListPaidReceipts.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListPaidReceipts.populated.json new file mode 100644 index 0000000..2e6216d --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListPaidReceipts.populated.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "bank": "Northwind Bank", + "clientId": "NWIND", + "coName": "Northwind Traders", + "dateCreated": "2026-04-01T00:00:00", + "empUpdated": "BOB", + "invoiceIds": [ + 108 + ], + "note": "EFT", + "paidTotal": -1100, + "paymentDate": "2026-04-01T00:00:00", + "saleReceiptId": 142, + "saleReceiptPaids": [], + "saleReceiptType": { + "id": "EFT", + "typeName": "EFT", + "typeSign": "-" + }, + "unallocated": 0 + } + ], + "total": 1 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListProducts.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListProducts.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListProducts.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListProducts.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListProducts.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListProducts.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListProducts.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListProducts.populated.json new file mode 100644 index 0000000..cbf1450 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListProducts.populated.json @@ -0,0 +1,28 @@ +[ + { + "allowDiscount": true, + "allowDiscount2": false, + "dateCreated": "2026-01-01T00:00:00", + "dateUpdated": "2026-01-01T00:00:00", + "displayOnWeb": false, + "head": "Prepaid", + "isTraining": false, + "note": "Prepaid hours", + "productId": "PREPAID", + "productName": "Prepaid block", + "skus": [ + { + "costAmt": 0, + "dateCreated": "2026-01-01T00:00:00", + "dateUpdated": "2026-01-01T00:00:00", + "displayOnWeb": false, + "isPrepaid": true, + "productId": "PREPAID", + "rrpAmt": 1000, + "sellAmt": 1000, + "skuId": "PREPAID-10", + "skuName": "Prepaid 10 hours" + } + ] + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListRecurringInvoices.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListRecurringInvoices.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListRecurringInvoices.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListRecurringInvoices.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListRecurringInvoices.empty.json new file mode 100644 index 0000000..e88e669 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListRecurringInvoices.empty.json @@ -0,0 +1,4 @@ +{ + "data": [], + "total": 0 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListRecurringInvoices.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListRecurringInvoices.populated.json new file mode 100644 index 0000000..d11b770 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/ListRecurringInvoices.populated.json @@ -0,0 +1,17 @@ +{ + "data": [ + { + "clientId": "NWIND", + "clientName": "Northwind Traders", + "countOfInv": 3, + "createdOn": "2026-01-01T00:00:00", + "id": 42, + "isActive": true, + "lastInvEndDate": "2026-03-31T00:00:00", + "note": "Monthly retainer", + "sellTotal": 1100, + "unit": "month" + } + ], + "total": 1 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/QueryTimesheets.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/QueryTimesheets.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/QueryTimesheets.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/QueryTimesheets.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/QueryTimesheets.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/QueryTimesheets.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/QueryTimesheets.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/QueryTimesheets.populated.json new file mode 100644 index 0000000..b952ed8 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/QueryTimesheets.populated.json @@ -0,0 +1,19 @@ +[ + { + "billableId": "B", + "categoryId": "WEBDEV", + "categoryName": "Web development", + "clientId": "NWIND", + "clientName": "Northwind Traders", + "description": "Product search", + "empId": "BOB", + "employeeName": "Bob Northwind", + "note": "Product search", + "projectId": "1I776Q", + "projectName": "Northwind Traders", + "sellPrice": 175, + "timeId": 4242, + "timesheetDate": "2026-03-16", + "totalHours": 7.5 + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/SearchClients.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/SearchClients.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/SearchClients.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/SearchClients.empty.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/SearchClients.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/SearchClients.empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/SearchClients.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/SearchClients.populated.json new file mode 100644 index 0000000..c0ca60e --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/SearchClients.populated.json @@ -0,0 +1,6 @@ +[ + { + "text": "Northwind Traders", + "value": "NWIND" + } +] diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateLeave.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateLeave.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateLeave.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateLeave.dryRun.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateLeave.dryRun.json new file mode 100644 index 0000000..89425f4 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateLeave.dryRun.json @@ -0,0 +1,23 @@ +{ + "changes": [ + "Note -\u003E Family day (updated)" + ], + "dryRun": true, + "leaveId": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa", + "request": { + "allDay": true, + "approvedBy": "bob@northwind.example", + "endDate": "2026-04-01T23:59:00.0000000\u002B10:00", + "id": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa", + "leaveTypeId": 1, + "note": "Family day (updated)", + "optionalEmp": [ + "bob@northwind.example" + ], + "requestedEmpId": "BOB", + "startDate": "2026-04-01T00:00:00.0000000\u002B10:00", + "timeLessOverride": null, + "userEndTime": "18:00:00", + "userStartTime": "09:00:00" + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateTimesheet.apiError.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateTimesheet.apiError.json new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateTimesheet.apiError.json @@ -0,0 +1,6 @@ +{ + "message": "TimePro API returned 500 Internal Server Error", + "responseBody": "{\u0022title\u0022:\u0022Server error\u0022,\u0022detail\u0022:\u0022Northwind API is unavailable\u0022}", + "statusCode": 500, + "threw": "ApiException" +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateTimesheet.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateTimesheet.populated.json new file mode 100644 index 0000000..a35361c --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/UpdateTimesheet.populated.json @@ -0,0 +1,36 @@ +{ + "iterationApplied": null, + "message": null, + "success": true, + "timesheet": { + "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, + "inputSource": null, + "invoiceId": null, + "invoiceType": null, + "isBillable": true, + "isLeave": false, + "isLocked": false, + "isSuggested": false, + "iteration": "Checkout API", + "iterationId": null, + "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 + }, + "timesheetId": 4242, + "warning": null +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/CliBinary.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/CliBinary.cs new file mode 100644 index 0000000..e0ff426 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/CliBinary.cs @@ -0,0 +1,49 @@ +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// Locates the built CLI assembly so stdio tests can launch the real +/// tp mcp entry point as a child process. +/// +public static class CliBinary +{ + private const string AssemblyName = "SSW.TimePro.Cli.dll"; + + private static readonly Lazy Located = new(Locate); + + public static string Path => Located.Value; + + private static string Locate() + { + var repoRoot = FindRepoRoot(); + var projectBin = System.IO.Path.Combine(repoRoot, "src", "SSW.TimePro.Cli", "bin"); + + // The test project has a ProjectReference to the CLI, so building the tests always + // produces this assembly alongside its runtimeconfig.json. + var candidates = Directory.Exists(projectBin) + ? Directory.GetFiles(projectBin, AssemblyName, SearchOption.AllDirectories) + .Where(p => File.Exists(System.IO.Path.ChangeExtension(p, ".runtimeconfig.json"))) + .OrderByDescending(File.GetLastWriteTimeUtc) + .ToList() + : []; + + if (candidates.Count == 0) + throw new InvalidOperationException( + $"Could not find a runnable {AssemblyName} under {projectBin}. Run 'dotnet build' first."); + + return candidates[0]; + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(System.IO.Path.Combine(dir.FullName, "Directory.Build.props"))) + return dir.FullName; + dir = dir.Parent; + } + + throw new InvalidOperationException( + $"Could not find the repository root above {AppContext.BaseDirectory}."); + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpParityTests.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpParityTests.cs new file mode 100644 index 0000000..9fb6dfb --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpParityTests.cs @@ -0,0 +1,163 @@ +using FluentAssertions; +using SSW.TimePro.Cli.Infrastructure.Cli; +using Xunit; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// Runs a CLI command and its MCP tool against identical, freshly reset Northwind state and +/// compares the complete parsed documents plus the HTTP traffic each produced. +/// +public class CliMcpParityTests : TestBase +{ + public static TheoryData ExecutableRows() + { + var data = new TheoryData(); + for (var i = 0; i < McpCliParityTable.Rows.Count; i++) + if (McpCliParityTable.Rows[i].IsExecutable) + data.Add(i); + return data; + } + + [Theory] + [MemberData(nameof(ExecutableRows))] + public async Task CliAndMcp_DifferOnlyWherePermitted(int rowIndex) + { + var row = McpCliParityTable.Rows[rowIndex]; + var ct = TestContext.Current.CancellationToken; + + var cliJson = Normalize(row, await RunCliAsync(row, ct)); + var cliRequests = CapturedRequests(); + + WireMock.Reset(); + + var mcpJson = Normalize(row, await RunToolAsync(row, ct)); + var mcpRequests = CapturedRequests(); + + Golden.Verify($"Parity/{row.ToolMethod}.{rowIndex}.cli.json", cliJson); + Golden.Verify($"Parity/{row.ToolMethod}.{rowIndex}.mcp.json", mcpJson); + + var differences = JsonDiff.Paths(cliJson, mcpJson); + + if (row.ExpectParity) + { + differences.Should().BeEmpty( + $"{row.ToolMethod} is expected at parity.\nCLI:\n{cliJson}\nMCP:\n{mcpJson}"); + } + else + { + row.Note.Should().NotBeNullOrWhiteSpace("a non-parity row must say why"); + differences.Should().BeSubsetOf(row.PermittedDifferences, + $"{row.ToolMethod} may only differ where declared.\nCLI:\n{cliJson}\nMCP:\n{mcpJson}"); + } + + AssertTraffic(row, "CLI", cliRequests); + AssertTraffic(row, "MCP", mcpRequests); + } + + [Fact] + public void Table_CoversEveryRegisteredTool() + { + var declared = McpCliParityTable.Rows.Select(r => r.ToolMethod).ToHashSet(StringComparer.Ordinal); + + declared.Should().BeEquivalentTo(McpToolInventory.AllMethodNames); + } + + [Fact] + public void EveryToolMapsToARegisteredCliCommand_OrIsAllowlisted() + { + var unmapped = new List(); + + foreach (var method in McpToolInventory.AllMethods) + { + var rows = McpCliParityTable.Rows.Where(r => r.ToolMethod == method.Name).ToList(); + rows.Should().NotBeEmpty($"{method.Name} needs a row in the CLI/MCP table"); + + var path = rows[0].CliCommandPath; + if (path is null) + { + McpCliParityTable.ToolsWithoutCliMirror.Should().Contain(method.Name, + $"{method.Name} has no CLI command and is not on the shrink-only allowlist"); + unmapped.Add(method.Name); + continue; + } + + McpCliParityTable.ToolsWithoutCliMirror.Should().NotContain(method.Name, + $"{method.Name} now maps to '{path}', so remove it from the allowlist"); + + Resolve(path).Should().BeTrue($"'tp {path}' is not a registered command"); + } + + unmapped.Should().BeSubsetOf(McpCliParityTable.ToolsWithoutCliMirror, + "the no-CLI-mirror allowlist may only shrink"); + } + + private static bool Resolve(string commandPath) + { + var node = CommandCatalog.Root; + foreach (var segment in commandPath.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + node = node.Child(segment); + if (node is null) + return false; + } + + return true; + } + + private async Task RunCliAsync(ParityRow row, CancellationToken ct) + { + NorthwindApi.StubAll(WireMock); + row.Arrange?.Invoke(WireMock); + + var result = await CliRunner.RunAsync( + row.CliArgs!, ApiClient, McpToolCatalog.Config(WireMock.Url!), ct); + + result.Stdout.Should().NotBeNullOrWhiteSpace( + $"'tp {string.Join(' ', row.CliArgs!)}' wrote nothing to stdout. stderr:\n{result.Stderr}"); + + return result.Stdout; + } + + private async Task RunToolAsync(ParityRow row, CancellationToken ct) + { + NorthwindApi.StubAll(WireMock); + row.Arrange?.Invoke(WireMock); + + var host = new McpToolHost(ApiClient, McpToolCatalog.Config(WireMock.Url!)); + return await row.InvokeTool!(host, ct); + } + + private List<(string Method, string Path, string? Body)> CapturedRequests() => + WireMock.LogEntries + .Select(e => (e.RequestMessage!.Method, e.RequestMessage.AbsolutePath, e.RequestMessage.Body)) + .ToList(); + + private static void AssertTraffic( + ParityRow row, string side, List<(string Method, string Path, string? Body)> requests) + { + foreach (var expected in row.ExpectedRequests) + { + var matches = requests + .Where(r => string.Equals(r.Method, expected.Method, StringComparison.OrdinalIgnoreCase) + && r.Path == expected.Path) + .ToList(); + + matches.Should().HaveCount(expected.Count, + $"{side} {row.ToolMethod}: {expected.Method} {expected.Path}"); + + foreach (var fragment in expected.BodyContains) + matches.Should().Contain(r => r.Body != null && r.Body.Contains(fragment), + $"{side} {row.ToolMethod}: payload must contain {fragment}"); + } + + foreach (var forbidden in row.ForbiddenRequests) + requests.Should().NotContain( + r => r.Path == forbidden.Path + && string.Equals(r.Method, forbidden.Method, StringComparison.OrdinalIgnoreCase), + $"{side} {row.ToolMethod} must not send {forbidden.Method} {forbidden.Path}"); + } + + private static string Normalize(ParityRow row, string json) => + row.TokenizeCurrentWeek ? WeekTokens.Apply(json) : json; +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/CliRunner.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/CliRunner.cs new file mode 100644 index 0000000..b494dd8 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/CliRunner.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.DependencyInjection; +using SSW.TimePro.Cli.Features.Leave; +using SSW.TimePro.Cli.Features.Timesheets; +using SSW.TimePro.Cli.Infrastructure.ApiClient; +using SSW.TimePro.Cli.Infrastructure.Cli; +using SSW.TimePro.Cli.Infrastructure.Config; +using SSW.TimePro.Cli.Infrastructure.DependencyInjection; +using SSW.TimePro.Cli.Infrastructure.Output; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// Runs the real command tree in-process against a supplied API client, capturing stdout so a +/// parity case can compare a CLI --json document with its MCP counterpart. +/// +public static class CliRunner +{ + public sealed record Result(int ExitCode, string Stdout, string Stderr); + + public static async Task RunAsync( + string[] args, ITimeProApiClient api, IConfigService config, CancellationToken ct) + { + var services = new ServiceCollection(); + services.AddSingleton(api); + services.AddSingleton(config); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + var jsonRequested = CommandLineErrorHandler.IsJsonRequested(args); + var app = new CommandApp(new TypeRegistrar(services)); + app.Configure(configurator => + { + configurator.SetExceptionHandler((ex, _) => CommandLineErrorHandler.Handle(ex, jsonRequested, args)); + CliConfiguration.Configure(configurator); + }); + + var originalOut = Console.Out; + var originalError = Console.Error; + var originalConsole = AnsiConsole.Console; + + var stdout = new StringWriter(); + var stderr = new StringWriter(); + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(stdout) + }); + + var exitCode = await app.RunAsync(args, ct); + return new Result(exitCode, stdout.ToString(), stderr.ToString()); + } + finally + { + AnsiConsole.Console = originalConsole; + Console.SetOut(originalOut); + Console.SetError(originalError); + } + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/Golden.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/Golden.cs new file mode 100644 index 0000000..f174973 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/Golden.cs @@ -0,0 +1,151 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// Checked-in JSON snapshots for the MCP harness. Files are written with sorted keys so a +/// contract change shows up as a reviewable diff rather than reordered noise; regenerate +/// deliberately with UPDATE_MCP_GOLDENS=1 dotnet test. +/// +public static class Golden +{ + private const string UpdateEnvVar = "UPDATE_MCP_GOLDENS"; + + private static readonly Lazy Directory = new(FindGoldensDirectory); + + public static bool UpdateRequested => + Environment.GetEnvironmentVariable(UpdateEnvVar) is "1" or "true"; + + public static string PathFor(string relativePath) => + System.IO.Path.Combine(Directory.Value, relativePath.Replace('/', System.IO.Path.DirectorySeparatorChar)); + + /// + /// Compares with the golden at . + /// Both sides are canonicalised (sorted keys, two-space indent) before comparison, so + /// property order and whitespace are not part of the contract but every value is. + /// + public static void Verify(string relativePath, string actualJson) + { + var canonical = Canonicalize(actualJson); + var path = PathFor(relativePath); + + if (UpdateRequested || !File.Exists(path)) + { + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + File.WriteAllText(path, canonical + "\n", new UTF8Encoding(false)); + + if (!UpdateRequested) + throw new Xunit.Sdk.XunitException( + $"Golden '{relativePath}' did not exist; it has been written. Review and commit it."); + + return; + } + + var expected = File.ReadAllText(path).Replace("\r\n", "\n").TrimEnd('\n'); + if (expected == canonical) + return; + + throw new Xunit.Sdk.XunitException( + $""" + MCP golden mismatch: {relativePath} + Re-run with UPDATE_MCP_GOLDENS=1 only after confirming the change is intended. + + --- expected (checked in) --- + {expected} + + --- actual --- + {canonical} + """); + } + + /// Non-JSON snapshot (plain text), same update semantics. + public static void VerifyText(string relativePath, string actual) + { + var normalized = actual.Replace("\r\n", "\n").TrimEnd('\n'); + var path = PathFor(relativePath); + + if (UpdateRequested || !File.Exists(path)) + { + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + File.WriteAllText(path, normalized + "\n", new UTF8Encoding(false)); + + if (!UpdateRequested) + throw new Xunit.Sdk.XunitException( + $"Golden '{relativePath}' did not exist; it has been written. Review and commit it."); + + return; + } + + var expected = File.ReadAllText(path).Replace("\r\n", "\n").TrimEnd('\n'); + if (expected != normalized) + throw new Xunit.Sdk.XunitException( + $""" + MCP golden mismatch: {relativePath} + + --- expected (checked in) --- + {expected} + + --- actual --- + {normalized} + """); + } + + public static string Canonicalize(string json) + { + JsonNode? node; + try + { + node = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions + { + AllowTrailingCommas = true + }); + } + catch (JsonException ex) + { + throw new Xunit.Sdk.XunitException($"Tool returned non-JSON text: {ex.Message}\n{json}"); + } + + var sorted = Sort(node); + return sorted is null + ? "null" + : sorted.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + private static JsonNode? Sort(JsonNode? node) + { + switch (node) + { + case JsonObject obj: + var sorted = new JsonObject(); + foreach (var pair in obj.ToList().OrderBy(p => p.Key, StringComparer.Ordinal)) + sorted[pair.Key] = Sort(pair.Value?.DeepClone()); + return sorted; + + case JsonArray array: + var result = new JsonArray(); + foreach (var item in array.ToList()) + result.Add(Sort(item?.DeepClone())); + return result; + + default: + return node?.DeepClone(); + } + } + + private static string FindGoldensDirectory() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = System.IO.Path.Combine( + dir.FullName, "tests", "SSW.TimePro.Cli.Integration", "Goldens", "Mcp"); + if (System.IO.Directory.Exists(candidate)) + return candidate; + dir = dir.Parent; + } + + throw new InvalidOperationException("Could not locate tests/SSW.TimePro.Cli.Integration/Goldens/Mcp."); + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/IsolatedConfigDirectory.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/IsolatedConfigDirectory.cs new file mode 100644 index 0000000..ec8e295 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/IsolatedConfigDirectory.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using SSW.TimePro.Cli.Infrastructure.Config; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// A throwaway ~/.config/timepro-cli handed to the child CLI through +/// . Stdio tests must never read or write the +/// developer's real config. +/// +public sealed class IsolatedConfigDirectory : IDisposable +{ + public const string TenantName = "northwind-test"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public string Root { get; } + + public IsolatedConfigDirectory(string apiUrl, bool accountingEnabled) + { + Root = Path.Combine(Path.GetTempPath(), "tp-mcp-harness", Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(Path.Combine(Root, "tenants")); + + var global = new GlobalConfig { ActiveTenant = TenantName }; + if (accountingEnabled) + global.Features[FeatureCatalog.Accounting] = new FeatureConfig { Enabled = true, Version = 1 }; + + File.WriteAllText( + Path.Combine(Root, "config.json"), + JsonSerializer.Serialize(global, JsonOptions)); + + File.WriteAllText( + Path.Combine(Root, "tenants", $"{TenantName}.json"), + JsonSerializer.Serialize( + new TenantConfig + { + TenantId = "northwind", + ApiUrl = apiUrl, + ApiKey = "test-api-key", + EmployeeId = "BOB", + EmployeeName = "Bob Northwind" + }, + JsonOptions)); + } + + public void Dispose() + { + try + { + if (Directory.Exists(Root)) + Directory.Delete(Root, recursive: true); + } + catch (IOException) + { + // A leftover temp directory must never fail a test run. + } + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/JsonDiff.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/JsonDiff.cs new file mode 100644 index 0000000..7b58f0a --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/JsonDiff.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// Reports the JSON paths at which two documents differ, including keys present on only one side. +/// Parity cases assert the difference set, never a normalised document. +/// +public static class JsonDiff +{ + public static IReadOnlyList Paths(string leftJson, string rightJson) + { + var differences = new List(); + Walk(JsonNode.Parse(leftJson), JsonNode.Parse(rightJson), "$", differences); + return differences.Distinct().Order(StringComparer.Ordinal).ToList(); + } + + private static void Walk(JsonNode? left, JsonNode? right, string path, List differences) + { + if (left is null && right is null) + return; + + if (left is JsonObject leftObject && right is JsonObject rightObject) + { + foreach (var key in leftObject.Select(p => p.Key).Union(rightObject.Select(p => p.Key)).Order(StringComparer.Ordinal)) + { + var hasLeft = leftObject.TryGetPropertyValue(key, out var leftValue); + var hasRight = rightObject.TryGetPropertyValue(key, out var rightValue); + + if (hasLeft != hasRight) + differences.Add($"{path}.{key}"); + else + Walk(leftValue, rightValue, $"{path}.{key}", differences); + } + + return; + } + + if (left is JsonArray leftArray && right is JsonArray rightArray) + { + if (leftArray.Count != rightArray.Count) + { + differences.Add($"{path}.length"); + return; + } + + for (var i = 0; i < leftArray.Count; i++) + Walk(leftArray[i], rightArray[i], $"{path}[{i}]", differences); + + return; + } + + var leftText = left?.ToJsonString() ?? "null"; + var rightText = right?.ToJsonString() ?? "null"; + if (!NumericallyEqual(left, right) && leftText != rightText) + differences.Add(path); + } + + /// Treats 7.5 and 7.50 as equal; JSON number formatting is not a contract. + private static bool NumericallyEqual(JsonNode? left, JsonNode? right) => + left is JsonValue leftValue + && right is JsonValue rightValue + && leftValue.GetValueKind() == JsonValueKind.Number + && rightValue.GetValueKind() == JsonValueKind.Number + && leftValue.GetValue() == rightValue.GetValue(); +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs new file mode 100644 index 0000000..a31d525 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs @@ -0,0 +1,241 @@ +using WireMock.Server; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// Expected HTTP traffic for a write case: same method, path, payload and count on both sides. +public sealed record ExpectedRequest(string Method, string Path, int Count = 1) +{ + /// Fragments the request body must contain, checked verbatim. + public string[] BodyContains { get; init; } = []; +} + +/// +/// One row per MCP tool: its CLI counterpart, whether the two are expected to agree today, and +/// the differences that are permitted while they do not. +/// +public sealed record ParityRow(string ToolMethod, string? CliCommandPath) +{ + /// Full argv run through the in-process command app; null means "not executed yet". + public string[]? CliArgs { get; init; } + + public Func>? InvokeTool { get; init; } + + /// True once CLI and MCP return the same document for this case. + public bool ExpectParity { get; init; } + + /// + /// JSON paths allowed to differ. Anything else differing fails: no generic normalisation. + /// + public string[] PermittedDifferences { get; init; } = []; + + public Action? Arrange { get; init; } + + public ExpectedRequest[] ExpectedRequests { get; init; } = []; + + /// Requests that must not happen at all — the write-safety half of a parity case. + public ExpectedRequest[] ForbiddenRequests { get; init; } = []; + + /// Why this row is not at parity yet. Required whenever is false. + public string? Note { get; init; } + + public bool TokenizeCurrentWeek { get; init; } + + public bool IsExecutable => CliArgs is not null && InvokeTool is not null; + + public override string ToString() => ToolMethod; +} + +/// +/// The CLI/MCP pairing for all 47 tools. Rows flip ExpectParity to true as the unification +/// slices land; the five tools with no CLI command at all are the shrink-only allowlist. +/// +public static class McpCliParityTable +{ + private const string Date = NorthwindApi.AnyDate; + + /// + /// Tools with no CLI counterpart today. This list may only shrink: adding to it means shipping + /// behaviour that exists only behind MCP, which AGENTS.md forbids. + /// + public static IReadOnlySet ToolsWithoutCliMirror { get; } = new HashSet(StringComparer.Ordinal) + { + "GetInvoicesByClient", + "GetUnpaidInvoicesByClient", + "ListCategories", + "ListBillableTypes", + "ListAllSkus", + }; + + public static IReadOnlyList Rows { get; } = + [ + // ───────── Mirrored pairs over a shared service ───────── + + new("UpdateTimesheet", "ts update") + { + CliArgs = ["ts", "update", "4242", "--description", "Order history", "--date", Date, "--yes", "--json"], + InvokeTool = (h, ct) => h.Timesheets.UpdateTimesheet( + NorthwindApi.TimesheetId, description: "Order history", date: Date, ct: ct), + ExpectParity = false, + PermittedDifferences = + [ + "$.iterationApplied", "$.message", "$.warning", + "$.timesheet.inputSource", "$.timesheet.invoiceId", + "$.timesheet.invoiceType", "$.timesheet.iterationId" + ], + Note = "CLI omits nulls; MCP writes them. TimesheetUpdateService already shared.", + ExpectedRequests = + [ + new("POST", "/api/Timesheets/SaveTimesheet") + { + BodyContains = ["\"timeID\":4242", "\"Notes\":\"Order history\"", "\"sellPrice\":175"] + } + ] + }, + + new("AcceptSuggestedTimesheet", "ts accept") + { + CliArgs = ["ts", "accept", "4243", "--iteration", "Order history", "--date", Date, "--yes", "--json"], + InvokeTool = (h, ct) => h.Timesheets.AcceptSuggestedTimesheet( + NorthwindApi.SuggestedTimesheetId, iteration: "Order history", date: Date, ct: ct), + ExpectParity = false, + PermittedDifferences = + [ + "$.message", "$.warning", + "$.timesheet.inputSource", "$.timesheet.invoiceId", + "$.timesheet.invoiceType", "$.timesheet.iterationId" + ], + Note = "CLI omits nulls; MCP writes them. TimesheetAcceptService already shared.", + ExpectedRequests = + [ + new("POST", "/api/Timesheets/AcceptSuggestedTimesheet"), + new("POST", "/api/Timesheets/SaveTimesheet") + ] + }, + + new("DeleteTimesheet", "ts delete") + { + CliArgs = ["ts", "delete", "4242", "--date", Date, "--yes", "--json"], + InvokeTool = (h, ct) => h.Timesheets.DeleteTimesheet(NorthwindApi.TimesheetId, date: Date, ct: ct), + ExpectParity = true, + ExpectedRequests = [new("DELETE", $"/api/Timesheets/DeleteTimesheet/{NorthwindApi.TimesheetId}")] + }, + + new("DeleteTimesheet", "ts delete") + { + CliArgs = ["ts", "delete", "4243", "--date", Date, "--yes", "--json"], + InvokeTool = (h, ct) => h.Timesheets.DeleteTimesheet(NorthwindApi.SuggestedTimesheetId, date: Date, ct: ct), + ExpectParity = false, + PermittedDifferences = ["$.error"], + Note = "Both refuse the suggestion and write nothing, but the CLI renders the shared " + + "message through the --json error envelope while MCP returns a bare error string.", + ForbiddenRequests = + [ + new("DELETE", "/api/Timesheets/DeleteTimesheet/4243"), + new("DELETE", "/api/Timesheets/DeleteTimesheet/4242") + ] + }, + + new("CheckWeek", "ts check") + { + 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.", + TokenizeCurrentWeek = true + }, + + new("CreateLeave", "leave create") + { + CliArgs = ["leave", "create", "--start", "2026-04-01", "--end", "2026-04-01", + "--type", "Annual Leave", "--note", "Family day", + "--approved-by", NorthwindApi.EmpEmail, "--cc", NorthwindApi.EmpEmail, + "--timezone", McpToolCatalog.TimeZone, "--dry-run", "--json"], + InvokeTool = (h, ct) => h.Leave.CreateLeave( + start: "2026-04-01", end: "2026-04-01", type: "Annual Leave", note: "Family day", + approvedBy: NorthwindApi.EmpEmail, cc: NorthwindApi.EmpEmail, + timeZoneId: McpToolCatalog.TimeZone, dryRun: true, ct: ct), + ExpectParity = false, + PermittedDifferences = ["$.request.timeLessOverride"], + Note = "LeaveCreateService is shared; the CLI omits the null TimeLessOverride.", + ForbiddenRequests = [new("POST", "/api/leave/")] + }, + + new("UpdateLeave", "leave update") + { + CliArgs = ["leave", "update", NorthwindApi.LeaveId, "--note", "Family day (updated)", + "--timezone", McpToolCatalog.TimeZone, "--dry-run", "--json"], + InvokeTool = (h, ct) => h.Leave.UpdateLeave( + id: NorthwindApi.LeaveId, note: "Family day (updated)", + timeZoneId: McpToolCatalog.TimeZone, dryRun: true, ct: ct), + ExpectParity = false, + PermittedDifferences = ["$.request.timeLessOverride"], + Note = "LeaveUpdateService is shared; the CLI omits the null TimeLessOverride." + }, + + new("ImportLeaveBalances", "leave balances import") + { + CliArgs = ["leave", "balances", "import", LeaveBalancesCsv.Path, "--yes", "--json"], + InvokeTool = (h, ct) => h.Accounting.ImportLeaveBalances(LeaveBalancesCsv.Path, ct), + ExpectParity = false, + PermittedDifferences = ["$.success"], + Note = "LeaveBalanceImportService is shared; only the MCP success flag differs.", + ExpectedRequests = [new("POST", "/api/leave/balances/import") { BodyContains = ["Bob Northwind"] }] + }, + + new("GetLeaveBalanceStatus", "leave balances status") + { + CliArgs = ["leave", "balances", "status", "--json"], + InvokeTool = (h, ct) => h.Leave.GetLeaveBalanceStatus(ct), + ExpectParity = true + }, + + // ───────── 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("CreateTimesheet", "ts create") { Note = "No shared create orchestration yet (#57)." }, + new("ListIterations", "iteration list") { Note = "Separate projections." }, + new("GetSuggestedTimesheets", "ts suggest") { Note = "Separate projections." }, + new("SearchClients", "client search") { Note = "Separate projections." }, + new("GetProjectsForClient", "project list") { Note = "Sentinel row and rate shape (#46)." }, + new("GetClientRate", "rate get") { Note = "MCP returns the raw rate, CLI a lookup result (#46)." }, + new("GetCrmBookings", "booking list") { Note = "Separate projections." }, + 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." }, + new("ListInvoices", "invoice list") { Note = "Separate projections." }, + new("GetInvoice", "invoice get") { Note = "Separate projections." }, + new("GetInvoiceLines", "invoice lines") { Note = "Separate projections." }, + new("GetInvoiceTimesheets", "invoice timesheets") { Note = "Separate projections." }, + new("GetInvoiceReceipts", "invoice receipts") { Note = "Separate projections." }, + new("ListPaidReceipts", "receipt list") { Note = "Separate projections." }, + new("GetReceiptDetail", "receipt get") { Note = "Separate projections." }, + new("GetClientOutstanding", "receipt outstanding") { Note = "Separate projections." }, + new("ListCreditNotes", "creditnote list") { Note = "Separate projections." }, + new("ListProducts", "product list") { Note = "Separate projections." }, + new("GetProduct", "product get") { Note = "Separate projections." }, + new("GetProductDiscountsForClient", "product discounts") { Note = "Separate projections." }, + new("ListClientRates", "rate list") { Note = "Separate projections." }, + new("GetClientsWithOutstandingTime", "client outstanding") { Note = "Separate projections." }, + new("GetUnbilledTimesheetsForClient", "unbilled list") { Note = "Separate projections." }, + new("ListRecurringInvoices", "recurring list") { Note = "Separate projections." }, + new("GetRecurringInvoice", "recurring get") { Note = "Separate projections." }, + new("QueryTimesheets", "query") { Note = "Separate projections." }, + new("GetCurrentUser", "user me") { Note = "Separate projections." }, + new("ListLocations", "location info") { Note = "Separate projections." }, + new("GetProjectsSummary", "summary") { Note = "Separate projections." }, + new("GetPrepaidStatus", "prepaid summary") { Note = "Separate projections." }, + new("GetPrepaidStatusPdf", "prepaid status") { Note = "CLI writes a file; MCP returns base64." }, + + // TODO #38: give these a CLI command and drop them from the allowlist + + new("GetInvoicesByClient", null) { Note = "No CLI command; allowlisted." }, + new("GetUnpaidInvoicesByClient", null) { Note = "No CLI command; allowlisted." }, + new("ListCategories", null) { Note = "No CLI command; allowlisted." }, + new("ListBillableTypes", null) { Note = "No CLI command; allowlisted." }, + new("ListAllSkus", null) { Note = "No CLI command; allowlisted." }, + ]; + + public static IEnumerable Executable => Rows.Where(r => r.IsExecutable); +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpStdioClient.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpStdioClient.cs new file mode 100644 index 0000000..dec4ab9 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpStdioClient.cs @@ -0,0 +1,223 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// Minimal JSON-RPC-over-stdio client that drives the real tp mcp child process. +/// Kept deliberately dumb: it correlates ids, skips notifications, and drains stderr so a +/// crashing server surfaces its output instead of hanging the test. +/// +public sealed class McpStdioClient : IAsyncDisposable +{ + private const string ProtocolVersion = "2024-11-05"; + private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(60); + + private readonly Process _process; + private readonly StringBuilder _stderr = new(); + private readonly Queue _pending = new(); + private int _nextId = 1; + + private McpStdioClient(Process process) + { + _process = process; + } + + public string Stderr => _stderr.ToString(); + + public static async Task StartAsync( + string configRoot, + IEnumerable? extraArgs = null, + CancellationToken ct = default) + { + var info = new ProcessStartInfo("dotnet") + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + StandardOutputEncoding = Encoding.UTF8, + StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + }; + info.ArgumentList.Add(CliBinary.Path); + info.ArgumentList.Add("mcp"); + foreach (var arg in extraArgs ?? []) + info.ArgumentList.Add(arg); + + info.Environment["TIMEPRO_CLI_CONFIG_DIR"] = configRoot; + info.Environment["DOTNET_ENVIRONMENT"] = "Production"; + info.Environment["NO_COLOR"] = "1"; + + var process = Process.Start(info) + ?? throw new InvalidOperationException("Failed to start the MCP host process."); + + var client = new McpStdioClient(process); + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + lock (client._stderr) client._stderr.AppendLine(e.Data); + }; + process.BeginErrorReadLine(); + + await client.InitializeAsync(ct); + return client; + } + + private async Task InitializeAsync(CancellationToken ct) + { + await RequestAsync("initialize", new JsonObject + { + ["protocolVersion"] = ProtocolVersion, + ["capabilities"] = new JsonObject(), + ["clientInfo"] = new JsonObject + { + ["name"] = "tp-mcp-harness", + ["version"] = "1.0.0" + } + }, ct); + + await NotifyAsync("notifications/initialized", ct); + } + + /// Reads the whole paginated tool list. + public async Task> ListToolsAsync(CancellationToken ct = default) + { + var tools = new List(); + string? cursor = null; + + do + { + var parameters = new JsonObject(); + if (cursor is not null) + parameters["cursor"] = cursor; + + var result = await RequestAsync("tools/list", parameters, ct); + foreach (var tool in result["tools"]!.AsArray()) + tools.Add((JsonObject)tool!.DeepClone()); + + cursor = result["nextCursor"]?.GetValue(); + } + while (cursor is not null); + + return tools; + } + + public async Task CallToolAsync( + string name, JsonObject arguments, CancellationToken ct = default) + { + return await RequestAsync("tools/call", new JsonObject + { + ["name"] = name, + ["arguments"] = arguments + }, ct); + } + + public async Task RequestAsync(string method, JsonObject? parameters, CancellationToken ct) + { + var id = _nextId++; + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["method"] = method + }; + if (parameters is not null) + request["params"] = parameters; + + await WriteAsync(request, ct); + + var response = await ReadResponseAsync(id, ct); + if (response.TryGetPropertyValue("error", out var error)) + throw new InvalidOperationException( + $"{method} failed: {error!.ToJsonString()}{Environment.NewLine}stderr:{Environment.NewLine}{Stderr}"); + + return (JsonObject)response["result"]!; + } + + private async Task NotifyAsync(string method, CancellationToken ct) => + await WriteAsync(new JsonObject { ["jsonrpc"] = "2.0", ["method"] = method }, ct); + + private async Task WriteAsync(JsonObject frame, CancellationToken ct) + { + await _process.StandardInput.WriteLineAsync(frame.ToJsonString().AsMemory(), ct); + await _process.StandardInput.FlushAsync(ct); + } + + private async Task ReadResponseAsync(int id, CancellationToken ct) + { + for (var i = 0; i < _pending.Count; i++) + { + var queued = _pending.Dequeue(); + if (Matches(queued, id)) + return queued; + _pending.Enqueue(queued); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(ReadTimeout); + + while (true) + { + string? line; + try + { + line = await _process.StandardOutput.ReadLineAsync(timeout.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException( + $"Timed out waiting for response {id}.{Environment.NewLine}stderr:{Environment.NewLine}{Stderr}"); + } + + if (line is null) + throw new InvalidOperationException( + $"MCP host exited before answering request {id}.{Environment.NewLine}stderr:{Environment.NewLine}{Stderr}"); + + if (string.IsNullOrWhiteSpace(line)) + continue; + + JsonObject frame; + try + { + frame = (JsonObject)JsonNode.Parse(line)!; + } + catch (JsonException) + { + throw new InvalidOperationException( + $"Non-protocol output on the MCP stdout stream: {line}"); + } + + if (Matches(frame, id)) + return frame; + + // Server-initiated notifications and out-of-order responses. + if (frame.ContainsKey("id")) + _pending.Enqueue(frame); + } + } + + private static bool Matches(JsonObject frame, int id) => + frame.TryGetPropertyValue("id", out var value) + && value is not null + && value.GetValueKind() == JsonValueKind.Number + && value.GetValue() == id; + + public async ValueTask DisposeAsync() + { + try + { + _process.StandardInput.Close(); + if (!_process.WaitForExit(5000)) + _process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // Already gone. + } + + _process.Dispose(); + await Task.CompletedTask; + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpStdioDiscoveryTests.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpStdioDiscoveryTests.cs new file mode 100644 index 0000000..a73d7cf --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpStdioDiscoveryTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json.Nodes; +using FluentAssertions; +using Xunit; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// Discovery goldens taken from the real stdio server, not from reflection: names, descriptions, +/// input/output schemas and annotations as an MCP client actually sees them, with the accounting +/// pack off and on. +/// +[Collection(McpStdioCollection.Name)] +public class McpStdioDiscoveryTests +{ + public const int DefaultToolCount = 18; + public const int AccountingEnabledToolCount = 47; + + [Fact] + public async Task ToolsList_WithAccountingDisabled_MatchesGolden() + { + var tools = await ListToolsAsync(accountingEnabled: false); + + tools.Count.Should().Be(DefaultToolCount); + Golden.Verify("Discovery/tools-list.default.json", Describe(tools)); + } + + [Fact] + public async Task ToolsList_WithAccountingEnabled_MatchesGolden() + { + var tools = await ListToolsAsync(accountingEnabled: true); + + tools.Count.Should().Be(AccountingEnabledToolCount); + Golden.Verify("Discovery/tools-list.accounting.json", Describe(tools)); + } + + [Fact] + public async Task ToolsList_DifferenceBetweenGates_IsExactlyTheAccountingTools() + { + var withoutAccounting = Names(await ListToolsAsync(accountingEnabled: false)); + var withAccounting = Names(await ListToolsAsync(accountingEnabled: true)); + + withAccounting.Except(withoutAccounting).Should() + .BeEquivalentTo(McpToolInventory.AccountingToolNames); + withoutAccounting.Except(withAccounting).Should().BeEmpty(); + withoutAccounting.Should().BeEquivalentTo(McpToolInventory.DefaultToolNames); + } + + [Fact] + public async Task ToolsList_HasNoDuplicateNames() + { + var names = Names(await ListToolsAsync(accountingEnabled: true)); + + names.Should().OnlyHaveUniqueItems(); + } + + private static List Names(List tools) => + tools.Select(t => t["name"]!.GetValue()).Order(StringComparer.Ordinal).ToList(); + + /// + /// Projects the tool list into the golden shape: everything a client can see, with keys the + /// server omits left out so that adding or dropping annotations is a visible diff. + /// + private static string Describe(List tools) + { + var array = new JsonArray(); + foreach (var tool in tools.OrderBy(t => t["name"]!.GetValue(), StringComparer.Ordinal)) + array.Add(tool.DeepClone()); + + return new JsonObject + { + ["count"] = tools.Count, + ["tools"] = array + }.ToJsonString(); + } + + private static async Task> ListToolsAsync(bool accountingEnabled) + { + using var config = new IsolatedConfigDirectory("https://timepro.invalid", accountingEnabled); + await using var client = await McpStdioClient.StartAsync( + config.Root, ct: TestContext.Current.CancellationToken); + + return await client.ListToolsAsync(TestContext.Current.CancellationToken); + } +} + +[CollectionDefinition(Name, DisableParallelization = true)] +public class McpStdioCollection +{ + public const string Name = "mcp-stdio"; +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpStdioToolCallTests.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpStdioToolCallTests.cs new file mode 100644 index 0000000..f51b869 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpStdioToolCallTests.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Nodes; +using FluentAssertions; +using WireMock.Server; +using WireMock.Settings; +using Xunit; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// A representative slice of tools driven through real tools/call frames, so the envelope +/// itself — content type, isError, presence of structuredContent — is locked, and the +/// returned text is proven identical to the direct-call goldens. +/// +[Collection(McpStdioCollection.Name)] +public class McpStdioToolCallTests : IDisposable +{ + private readonly WireMockServer _wireMock = WireMockServer.Start(new WireMockServerSettings { Port = 0 }); + + public McpStdioToolCallTests() => NorthwindApi.StubAll(_wireMock); + + public static TheoryData Calls() => new() + { + { "get_timesheets", $$"""{"date":"{{NorthwindApi.AnyDate}}"}""" }, + { "list_iterations", $$"""{"projectId":"{{NorthwindApi.ProjectId}}"}""" }, + { "get_client_rate", $$"""{"clientId":"{{NorthwindApi.ClientId}}","date":"{{NorthwindApi.AnyDate}}"}""" }, + { "get_leave_entries", $$"""{"filter":"UPCOMING","limit":10,"empId":"{{NorthwindApi.EmpId}}"}""" }, + { "update_timesheet", $$"""{"timesheetId":{{NorthwindApi.TimesheetId}},"description":"Order history","date":"{{NorthwindApi.AnyDate}}"}""" }, + { "delete_timesheet", $$"""{"timesheetId":{{NorthwindApi.TimesheetId}},"date":"{{NorthwindApi.AnyDate}}"}""" }, + }; + + [Theory] + [MemberData(nameof(Calls))] + public async Task ToolCall_OverStdio_MatchesEnvelopeGolden(string toolName, string argumentsJson) + { + var result = await CallAsync(toolName, argumentsJson); + + Golden.Verify($"Calls/{toolName}.json", result.ToJsonString()); + } + + [Theory] + [InlineData("get_timesheets", "GetTimesheets", """{"date":"2026-03-16"}""")] + [InlineData("list_iterations", "ListIterations", """{"projectId":"1I776Q"}""")] + [InlineData("get_client_rate", "GetClientRate", """{"clientId":"NWIND","date":"2026-03-16"}""")] + [InlineData("get_leave_entries", "GetLeaveEntries", """{"filter":"UPCOMING","limit":10,"empId":"BOB"}""")] + [InlineData("update_timesheet", "UpdateTimesheet", """{"timesheetId":4242,"description":"Order history","date":"2026-03-16"}""")] + public async Task ToolCall_OverStdio_ReturnsTheSameTextAsTheDirectCall( + string toolName, string methodName, string argumentsJson) + { + var result = await CallAsync(toolName, argumentsJson); + + var text = result["content"]!.AsArray() + .Select(c => c!.AsObject()) + .Single(c => c["type"]!.GetValue() == "text")["text"]! + .GetValue(); + + var directCallGolden = File.ReadAllText(Golden.PathFor($"Tools/{methodName}.populated.json")); + + Golden.Canonicalize(text).Should().Be(Golden.Canonicalize(directCallGolden)); + } + + [Fact] + public async Task ToolCall_WhenTheApiFails_IsReportedAsAToolError() + { + NorthwindApi.Status( + _wireMock, "/api/Timesheets/GetTimesheetListViewModel", "GET", 500, + """{"title":"Server error"}""", NorthwindApi.OverridePriority); + + var result = await CallAsync("get_timesheets", $$"""{"date":"{{NorthwindApi.AnyDate}}"}"""); + + result["isError"]!.GetValue().Should().BeTrue(); + result["content"]!.AsArray().Should().NotBeEmpty(); + } + + private async Task CallAsync(string toolName, string argumentsJson) + { + using var config = new IsolatedConfigDirectory(_wireMock.Url!, accountingEnabled: false); + await using var client = await McpStdioClient.StartAsync( + config.Root, ct: TestContext.Current.CancellationToken); + + return await client.CallToolAsync( + toolName, + (JsonObject)JsonNode.Parse(argumentsJson)!, + TestContext.Current.CancellationToken); + } + + public void Dispose() + { + _wireMock.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCase.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCase.cs new file mode 100644 index 0000000..c4dda57 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCase.cs @@ -0,0 +1,39 @@ +using WireMock.Server; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// One golden case: how to invoke a tool, what to change about the shared Northwind API state +/// first, and which snapshot the raw returned JSON must match. +/// +public sealed record McpToolCase( + string ToolName, + string CaseName, + Func> Invoke) +{ + /// Route this tool reads or writes first; drives the generated empty/error cases. + public string? PrimaryRoute { get; init; } + + public string PrimaryMethod { get; init; } = "GET"; + + /// When set, an empty case replaces the primary route with this body. + public string? EmptyBody { get; init; } + + /// Set false for tools whose primary call cannot fail on its own (pure local reads). + public bool HasApiErrorCase { get; init; } = true; + + /// Extra WireMock state this case needs on top of the Northwind baseline. + public Action? Arrange { get; init; } + + /// + /// Replaces the checked week's Mon–Fri dates with tokens. Only for tools whose window is + /// derived from "today" and therefore cannot be pinned without a production clock seam. + /// + public bool TokenizeCurrentWeek { get; init; } + + public string GoldenPath => $"Tools/{ToolName}.{CaseName}.json"; + + public string DisplayName => $"{ToolName}/{CaseName}"; + + public override string ToString() => DisplayName; +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs new file mode 100644 index 0000000..e9ff96b --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs @@ -0,0 +1,432 @@ +using SSW.TimePro.Cli.Features.Mcp.Tools; +using SSW.TimePro.Cli.Infrastructure.Config; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// The single case catalog behind the MCP result goldens. One populated case per tool, plus the +/// generated empty and API-error variants declared on each case. +/// +public static class McpToolCatalog +{ + private const string Date = NorthwindApi.AnyDate; + private const string Client = NorthwindApi.ClientId; + private const string Project = NorthwindApi.ProjectId; + private const string Emp = NorthwindApi.EmpId; + + /// The fixed IANA zone every leave case pins, so dry-run payloads never drift. + public const string TimeZone = "Australia/Brisbane"; + + public static IReadOnlyList Populated { get; } = BuildPopulated(); + + public static IReadOnlyList All { get; } = Expand(Populated); + + public static IEnumerable ToolNames => Populated.Select(c => c.ToolName); + + private static IReadOnlyList BuildPopulated() => + [ + // ───────────────────────── Timesheets ───────────────────────── + + new("GetTimesheets", "populated", (h, ct) => h.Timesheets.GetTimesheets(Date, ct: ct)) + { + PrimaryRoute = "/api/Timesheets/GetTimesheetListViewModel", + EmptyBody = "[]" + }, + + new("CreateTimesheet", "populated", (h, ct) => h.Timesheets.CreateTimesheet( + Client, Project, Date, description: "Checkout API", iterationId: 3402, ct: ct)) + { + PrimaryRoute = "/api/Timesheets/SaveTimesheet", + PrimaryMethod = "POST" + }, + + new("ListIterations", "populated", (h, ct) => h.Timesheets.ListIterations(Project, ct)) + { + PrimaryRoute = "/api/ProjectIteration/GetIterationsForAddTimesheet", + EmptyBody = "[]" + }, + + new("UpdateTimesheet", "populated", (h, ct) => h.Timesheets.UpdateTimesheet( + NorthwindApi.TimesheetId, description: "Order history", date: Date, ct: ct)) + { + PrimaryRoute = "/api/Timesheets/SaveTimesheet", + PrimaryMethod = "POST" + }, + + new("DeleteTimesheet", "populated", (h, ct) => h.Timesheets.DeleteTimesheet( + NorthwindApi.TimesheetId, date: Date, ct: ct)) + { + PrimaryRoute = $"/api/Timesheets/DeleteTimesheet/{NorthwindApi.TimesheetId}", + PrimaryMethod = "DELETE" + }, + + new("CheckWeek", "populated", (h, ct) => h.Timesheets.CheckWeek(0, ct: ct)) + { + PrimaryRoute = "/api/Timesheets/GetTimesheetListViewModel", + TokenizeCurrentWeek = true + }, + + new("GetSuggestedTimesheets", "populated", (h, ct) => h.Timesheets.GetSuggestedTimesheets(Date, ct)) + { + PrimaryRoute = "/api/Timesheets/GetTimesheetListViewModel", + EmptyBody = "[]" + }, + + new("AcceptSuggestedTimesheet", "populated", (h, ct) => h.Timesheets.AcceptSuggestedTimesheet( + NorthwindApi.SuggestedTimesheetId, iteration: "Order history", date: Date, ct: ct)) + { + PrimaryRoute = "/api/Timesheets/AcceptSuggestedTimesheet", + PrimaryMethod = "POST" + }, + + // ───────────────────────── Lookups ───────────────────────── + + new("SearchClients", "populated", (h, ct) => h.Lookups.SearchClients("Northwind", ct)) + { + PrimaryRoute = "/api/Timesheets/GetClientListForAddTimesheet", + EmptyBody = "[]" + }, + + new("GetProjectsForClient", "populated", (h, ct) => h.Lookups.GetProjectsForClient(Client, ct)) + { + PrimaryRoute = "/api/Projects/GetSelectListUsageDataProject", + EmptyBody = "[]" + }, + + new("GetClientRate", "populated", (h, ct) => h.Lookups.GetClientRate(Client, Date, ct)) + { + PrimaryRoute = "/api/Timesheets/GetClientRate", + EmptyBody = "null" + }, + + new("GetCrmBookings", "populated", (h, ct) => h.Lookups.GetCrmBookings(Date, "2026-03-20", ct)) + { + PrimaryRoute = "/Crm/Appointments", + EmptyBody = "[]" + }, + + new("GetLocationAndMapping", "populated", + (h, _) => Task.FromResult(h.Lookups.GetLocationAndMapping("~/code/traders-app"))) + { + HasApiErrorCase = false + }, + + // ───────────────────────── Leave ───────────────────────── + + new("GetLeaveEntries", "populated", (h, ct) => h.Leave.GetLeaveEntries("UPCOMING", 10, Emp, ct: ct)) + { + PrimaryRoute = "/api/leave/", + EmptyBody = """{"leaves":{"pageNumber":1,"pageSize":10,"totalItems":0,"totalPages":0,"items":[]},"cancelledCount":0}""" + }, + + new("GetLeaveBalance", "populated", (h, ct) => h.Leave.GetLeaveBalance(Emp, ct: ct)) + { + PrimaryRoute = $"/api/leave/stats/{Emp}", + EmptyBody = "null" + }, + + new("CreateLeave", "dryRun", (h, ct) => h.Leave.CreateLeave( + start: "2026-04-01", + end: "2026-04-01", + type: "Annual Leave", + note: "Family day", + approvedBy: NorthwindApi.EmpEmail, + cc: NorthwindApi.EmpEmail, + timeZoneId: TimeZone, + dryRun: true, + ct: ct)) + { + PrimaryRoute = "/api/leave/types", + HasApiErrorCase = true + }, + + new("UpdateLeave", "dryRun", (h, ct) => h.Leave.UpdateLeave( + id: NorthwindApi.LeaveId, + note: "Family day (updated)", + timeZoneId: TimeZone, + dryRun: true, + ct: ct)) + { + PrimaryRoute = "/api/leave/", + HasApiErrorCase = true + }, + + new("GetLeaveBalanceStatus", "populated", (h, ct) => h.Leave.GetLeaveBalanceStatus(ct)) + { + PrimaryRoute = "/api/leave/balances/status", + EmptyBody = "null" + }, + + // ───────────────────────── Accounting ───────────────────────── + + new("ImportLeaveBalances", "populated", + (h, ct) => h.Accounting.ImportLeaveBalances(LeaveBalancesCsv.Path, ct)) + { + PrimaryRoute = "/api/leave/balances/import", + PrimaryMethod = "POST" + }, + + new("ListInvoices", "populated", (h, ct) => h.Accounting.ListInvoices(ct: ct)) + { + PrimaryRoute = "/api/ClientInvoice/rangepaged", + EmptyBody = """{"total":0,"data":[]}""" + }, + + new("GetInvoice", "populated", (h, ct) => h.Accounting.GetInvoice(NorthwindApi.InvoiceId, ct)) + { + PrimaryRoute = $"/api/v2/ClientInvoice/{NorthwindApi.InvoiceId}", + EmptyBody = "null" + }, + + new("GetInvoiceLines", "populated", (h, ct) => h.Accounting.GetInvoiceLines(NorthwindApi.InvoiceId, ct)) + { + PrimaryRoute = $"/api/v2/ClientInvoice/{NorthwindApi.InvoiceId}/products", + EmptyBody = "[]" + }, + + new("GetInvoiceTimesheets", "populated", + (h, ct) => h.Accounting.GetInvoiceTimesheets(NorthwindApi.InvoiceId, "allocated", ct)) + { + PrimaryRoute = "/api/v2/Timesheets/WithNames/Allocated", + EmptyBody = "[]" + }, + + new("GetInvoiceReceipts", "populated", + (h, ct) => h.Accounting.GetInvoiceReceipts(NorthwindApi.InvoiceId, ct)) + { + PrimaryRoute = $"/api/v2/ClientInvoice/{NorthwindApi.InvoiceId}/receipts", + EmptyBody = "[]" + }, + + new("GetInvoicesByClient", "populated", (h, ct) => h.Accounting.GetInvoicesByClient(Client, ct)) + { + PrimaryRoute = $"/api/ClientInvoice/ClientID/{Client}", + EmptyBody = "[]" + }, + + new("GetUnpaidInvoicesByClient", "populated", + (h, ct) => h.Accounting.GetUnpaidInvoicesByClient(Client, ct)) + { + PrimaryRoute = $"/api/ClientInvoice/UnpaidByClientID/{Client}", + EmptyBody = "[]" + }, + + new("ListPaidReceipts", "populated", (h, ct) => h.Accounting.ListPaidReceipts(ct: ct)) + { + PrimaryRoute = "/api/receipting/PaidReceiptsPaged", + EmptyBody = """{"total":0,"data":[]}""" + }, + + new("GetReceiptDetail", "populated", (h, ct) => h.Accounting.GetReceiptDetail(NorthwindApi.ReceiptId, ct)) + { + PrimaryRoute = $"/api/Receipting/details/{NorthwindApi.ReceiptId}", + EmptyBody = "null" + }, + + new("GetClientOutstanding", "populated", (h, ct) => h.Accounting.GetClientOutstanding(Client, ct)) + { + PrimaryRoute = $"/api/Receipting/ClientOutstanding/{Client}", + EmptyBody = "null" + }, + + new("ListCreditNotes", "populated", (h, ct) => h.Accounting.ListCreditNotes(Client, ct)) + { + PrimaryRoute = $"/api/creditnote/by-client/{Client}", + EmptyBody = "[]" + }, + + new("ListProducts", "populated", (h, ct) => h.Accounting.ListProducts(isExpand: true, ct: ct)) + { + PrimaryRoute = "/api/Product", + EmptyBody = "[]" + }, + + new("GetProduct", "populated", (h, ct) => h.Accounting.GetProduct("PREPAID", ct)) + { + PrimaryRoute = "/api/Product/PREPAID", + EmptyBody = "null" + }, + + new("ListAllSkus", "populated", (h, ct) => h.Accounting.ListAllSkus(isPrepaid: true, ct: ct)) + { + PrimaryRoute = "/api/Product/All", + EmptyBody = "[]" + }, + + new("GetProductDiscountsForClient", "populated", + (h, ct) => h.Accounting.GetProductDiscountsForClient(Client, ct)) + { + PrimaryRoute = $"/api/Product/GetDiscountsForClient/{Client}", + EmptyBody = "[]" + }, + + new("ListClientRates", "populated", (h, ct) => h.Accounting.ListClientRates(Client, ct: ct)) + { + PrimaryRoute = "/api/clients/GetClientRates", + EmptyBody = """{"rates":[],"total":0}""" + }, + + new("GetClientsWithOutstandingTime", "populated", + (h, ct) => h.Accounting.GetClientsWithOutstandingTime(ct)) + { + PrimaryRoute = "/api/clients/OutstandingTime", + EmptyBody = "[]" + }, + + new("GetUnbilledTimesheetsForClient", "populated", + (h, ct) => h.Accounting.GetUnbilledTimesheetsForClient(Client, ct: ct)) + { + PrimaryRoute = "/api/v2/Timesheets/WithNames/Unallocated", + EmptyBody = "[]" + }, + + new("ListRecurringInvoices", "populated", (h, ct) => h.Accounting.ListRecurringInvoices(ct: ct)) + { + PrimaryRoute = "/api/recurring/invoices/", + EmptyBody = """{"total":0,"data":[]}""" + }, + + new("GetRecurringInvoice", "populated", + (h, ct) => h.Accounting.GetRecurringInvoice(NorthwindApi.RecurringInvoiceId, ct)) + { + PrimaryRoute = $"/api/recurring/invoices/{NorthwindApi.RecurringInvoiceId}", + EmptyBody = "null" + }, + + new("QueryTimesheets", "populated", + (h, ct) => h.Accounting.QueryTimesheets("2026-03-01", "2026-03-31", empIds: [Emp], ct: ct)) + { + PrimaryRoute = "/api/timesheetSummary/GetTableSummarydata", + PrimaryMethod = "POST", + EmptyBody = "[]" + }, + + new("GetCurrentUser", "populated", (h, ct) => h.Accounting.GetCurrentUser(ct)) + { + PrimaryRoute = "/api/v2/users/me", + EmptyBody = "null" + }, + + new("ListCategories", "populated", (h, ct) => h.Accounting.ListCategories(ct)) + { + PrimaryRoute = "/api/Timesheets/GetTimesheetCategories", + EmptyBody = "[]" + }, + + new("ListBillableTypes", "populated", (h, ct) => h.Accounting.ListBillableTypes(ct)) + { + PrimaryRoute = "/api/Timesheets/GetTimesheetBillableType", + EmptyBody = "[]" + }, + + new("ListLocations", "populated", (h, ct) => h.Accounting.ListLocations(ct)) + { + PrimaryRoute = "/api/Timesheets/GetTimesheetLocation", + EmptyBody = "[]" + }, + + new("GetProjectsSummary", "populated", + (h, ct) => h.Accounting.GetProjectsSummary("2026-03-01", "2026-03-31", Emp, ct: ct)) + { + PrimaryRoute = "/api/Timesheets/GetProjectsSummary", + EmptyBody = "[]" + }, + + new("GetPrepaidStatus", "populated", (h, ct) => h.Accounting.GetPrepaidStatus(NorthwindApi.InvoiceId, ct)) + { + PrimaryRoute = $"/api/v2/ClientInvoice/{NorthwindApi.InvoiceId}", + EmptyBody = "null" + }, + + new("GetPrepaidStatusPdf", "populated", + (h, ct) => h.Accounting.GetPrepaidStatusPdf(NorthwindApi.InvoiceId, 0, ct)) + { + PrimaryRoute = "/Reporting/GetPrepaidStatusReport" + }, + ]; + + private static IReadOnlyList Expand(IReadOnlyList populated) + { + var cases = new List(populated); + + foreach (var populatedCase in populated) + { + if (populatedCase.PrimaryRoute is null) + continue; + + if (populatedCase.EmptyBody is not null) + { + var body = populatedCase.EmptyBody; + var route = populatedCase.PrimaryRoute; + var method = populatedCase.PrimaryMethod; + cases.Add(populatedCase with + { + CaseName = "empty", + Arrange = server => NorthwindApi.RawJson( + server, route, method, body, NorthwindApi.OverridePriority) + }); + } + + if (populatedCase.HasApiErrorCase) + { + var route = populatedCase.PrimaryRoute; + var method = populatedCase.PrimaryMethod; + cases.Add(populatedCase with + { + CaseName = "apiError", + Arrange = server => NorthwindApi.Status( + server, route, method, 500, + """{"title":"Server error","detail":"Northwind API is unavailable"}""", + NorthwindApi.OverridePriority) + }); + } + } + + return cases; + } + + public static TenantConfig Tenant(string apiUrl) => new() + { + TenantId = "northwind", + ApiUrl = apiUrl, + ApiKey = "test-api-key", + EmployeeId = Emp, + EmployeeName = NorthwindApi.EmpName, + ConfigName = "northwind-test" + }; + + public static TestConfigService Config(string apiUrl) => new(Tenant(apiUrl)) + { + RepoMappings = + [ + new() + { + PathPattern = "~/code/traders-app", + ClientId = Client, + ProjectId = Project, + ProjectName = NorthwindApi.ClientName, + CategoryId = "WEBDEV" + } + ] + }; +} + +/// A fixed Xero-style CSV on disk, so the import tool has a real path to read. +public static class LeaveBalancesCsv +{ + public static string Path { get; } = Create(); + + private static string Create() + { + var dir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "tp-mcp-harness-csv"); + Directory.CreateDirectory(dir); + var path = System.IO.Path.Combine(dir, "LeaveBalances.csv"); + File.WriteAllText(path, + """ + Employee,Leave Type,Balance (Hours) + Bob Northwind,Annual Leave,76.0 + Bobby Northwind,Annual Leave,12.0 + """); + return path; + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolHost.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolHost.cs new file mode 100644 index 0000000..31b3027 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolHost.cs @@ -0,0 +1,29 @@ +using SSW.TimePro.Cli.Features.Leave; +using SSW.TimePro.Cli.Features.Mcp.Tools; +using SSW.TimePro.Cli.Features.Timesheets; +using SSW.TimePro.Cli.Infrastructure.ApiClient; +using SSW.TimePro.Cli.Infrastructure.Config; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// The four MCP tool classes wired against a test API client, so a case catalog can invoke any +/// tool without repeating the dependency graph. +/// +public sealed class McpToolHost +{ + public McpToolHost(ITimeProApiClient api, IConfigService config) + { + var updates = new TimesheetUpdateService(api); + + Timesheets = new TimesheetMcpTools(api, config, updates, new TimesheetAcceptService(api, updates)); + Lookups = new LookupMcpTools(api, config); + Leave = new LeaveMcpTools(api, config, new LeaveCreateService(api), new LeaveUpdateService(api)); + Accounting = new AccountingMcpTools(api, config, new LeaveBalanceImportService(api)); + } + + public TimesheetMcpTools Timesheets { get; } + public LookupMcpTools Lookups { get; } + public LeaveMcpTools Leave { get; } + public AccountingMcpTools Accounting { get; } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolInventory.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolInventory.cs new file mode 100644 index 0000000..ac388a9 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolInventory.cs @@ -0,0 +1,71 @@ +using System.Reflection; +using System.Text; +using ModelContextProtocol.Server; +using SSW.TimePro.Cli.Features.Mcp.Tools; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// The registered tool surface, read from the attributes rather than a hand-kept list, so a new +/// tool immediately shows up as an uncovered case. +/// +public static class McpToolInventory +{ + public static IReadOnlyList DefaultToolTypes { get; } = + [typeof(TimesheetMcpTools), typeof(LookupMcpTools), typeof(LeaveMcpTools)]; + + public static Type AccountingToolType => typeof(AccountingMcpTools); + + public static IReadOnlyList DefaultMethodNames { get; } = + DefaultToolTypes.SelectMany(MethodNamesOn).Order(StringComparer.Ordinal).ToList(); + + public static IReadOnlyList AccountingMethodNames { get; } = + MethodNamesOn(AccountingToolType).Order(StringComparer.Ordinal).ToList(); + + public static IReadOnlyList AllMethodNames { get; } = + DefaultMethodNames.Concat(AccountingMethodNames).Order(StringComparer.Ordinal).ToList(); + + public static IReadOnlyList DefaultToolNames { get; } = + DefaultMethodNames.Select(WireName).Order(StringComparer.Ordinal).ToList(); + + public static IReadOnlyList AccountingToolNames { get; } = + AccountingMethodNames.Select(WireName).Order(StringComparer.Ordinal).ToList(); + + public static IReadOnlyList AllToolNames { get; } = + AllMethodNames.Select(WireName).Order(StringComparer.Ordinal).ToList(); + + public static IEnumerable Methods(Type toolType) => + toolType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static) + .Where(m => m.GetCustomAttribute() is not null); + + public static IEnumerable AllMethods => + DefaultToolTypes.Append(AccountingToolType).SelectMany(Methods); + + /// + /// The SDK publishes AcceptSuggestedTimesheet as accept_suggested_timesheet; + /// clients bind to the wire name, so every cross-check goes through here. + /// + public static string WireName(string methodName) + { + var builder = new StringBuilder(); + for (var i = 0; i < methodName.Length; i++) + { + var c = methodName[i]; + if (char.IsUpper(c)) + { + if (i > 0) + builder.Append('_'); + builder.Append(char.ToLowerInvariant(c)); + } + else + { + builder.Append(c); + } + } + + return builder.ToString(); + } + + private static IEnumerable MethodNamesOn(Type toolType) => + Methods(toolType).Select(m => m.GetCustomAttribute()!.Name ?? m.Name); +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolResultGoldenTests.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolResultGoldenTests.cs new file mode 100644 index 0000000..ebaf5b7 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolResultGoldenTests.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using FluentAssertions; +using SSW.TimePro.Cli.Infrastructure.ApiClient; +using Xunit; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// Locks the raw JSON text every MCP tool returns. These goldens are the extraction tripwire for +/// the CLI/MCP unification slices: an internal refactor that changes a tool's observable payload +/// fails here rather than in a client. +/// +public class McpToolResultGoldenTests : TestBase +{ + public static TheoryData Cases() + { + var data = new TheoryData(); + foreach (var toolCase in McpToolCatalog.All) + data.Add(toolCase.DisplayName); + return data; + } + + [Theory] + [MemberData(nameof(Cases))] + public async Task ToolResult_MatchesGolden(string caseName) + { + var toolCase = McpToolCatalog.All.Single(c => c.DisplayName == caseName); + + NorthwindApi.StubAll(WireMock); + toolCase.Arrange?.Invoke(WireMock); + + var host = new McpToolHost(ApiClient, McpToolCatalog.Config(WireMock.Url!)); + + string actual; + try + { + actual = await toolCase.Invoke(host, TestContext.Current.CancellationToken); + } + catch (ApiException ex) + { + // Today an API failure escapes the tool as a protocol-level error rather than an + // isError payload. Snapshot it so a change of policy is a reviewed change. + actual = JsonSerializer.Serialize(new + { + threw = nameof(ApiException), + statusCode = ex.StatusCode, + message = ex.Message, + responseBody = ex.ResponseBody + }); + } + + if (toolCase.TokenizeCurrentWeek) + actual = WeekTokens.Apply(actual); + + Golden.Verify(toolCase.GoldenPath, actual); + } + + [Fact] + public void Catalog_CoversEveryRegisteredTool() + { + var declared = McpToolCatalog.ToolNames.ToHashSet(StringComparer.Ordinal); + + McpToolInventory.AllMethodNames.Should().BeSubsetOf(declared); + declared.Should().BeSubsetOf(McpToolInventory.AllMethodNames); + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/NorthwindApi.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/NorthwindApi.cs new file mode 100644 index 0000000..c1c783f --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/NorthwindApi.cs @@ -0,0 +1,779 @@ +using System.Text.Json; +using SSW.TimePro.Cli.Shared.Models; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// One fake TimePro instance, all Northwind, shared by every MCP tool and CLI parity case. +/// +/// Response bodies are serialised from the real DTOs rather than hand-written JSON: a fixture +/// that no longer binds would otherwise show up as a golden full of nulls instead of a failure. +/// +public static class NorthwindApi +{ + public const string ClientId = "NWIND"; + public const string ClientName = "Northwind Traders"; + public const string ProjectId = "1I776Q"; + public const string EmpId = "BOB"; + public const string EmpName = "Bob Northwind"; + public const string EmpEmail = "bob@northwind.example"; + + public const int TimesheetId = 4242; + public const int SuggestedTimesheetId = 4243; + public const int InvoiceId = 108; + public const int ReceiptId = 142; + public const int RecurringInvoiceId = 42; + public const string LeaveId = "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa"; + + public const string AnyDate = "2026-03-16"; + + /// Baseline stubs sit below per-case overrides so a case can replace one route. + public const int BaselinePriority = 10; + public const int OverridePriority = 1; + + private static readonly JsonSerializerOptions Body = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// Registers every route the 47 MCP tools can reach, populated with Northwind data. + public static void StubAll(WireMockServer server) + { + StubIdentity(server); + StubLookups(server); + StubTimesheets(server); + StubLeave(server); + StubAccountingInvoices(server); + StubAccountingReceipts(server); + StubAccountingProducts(server); + StubAccountingMisc(server); + } + + public static void Json(WireMockServer server, string path, string method, object body, int priority = BaselinePriority) => + server.Given(Request.Create().WithPath(path).UsingMethod(method)) + .AtPriority(priority) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(JsonSerializer.Serialize(body, Body))); + + public static void RawJson(WireMockServer server, string path, string method, string body, int priority = BaselinePriority) => + server.Given(Request.Create().WithPath(path).UsingMethod(method)) + .AtPriority(priority) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(body)); + + public static void Status(WireMockServer server, string path, string method, int statusCode, string body = "", int priority = BaselinePriority) => + server.Given(Request.Create().WithPath(path).UsingMethod(method)) + .AtPriority(priority) + .RespondWith(Response.Create() + .WithStatusCode(statusCode) + .WithHeader("Content-Type", "application/json") + .WithBody(body)); + + // ───────────────────────── Identity ───────────────────────── + + private static void StubIdentity(WireMockServer server) + { + Json(server, "/api/Employees/GetEmployeeID", "GET", new EmployeeIdResponse { EmpId = EmpId }); + + Json(server, "/api/v2/users/me", "GET", new CurrentUserResponse + { + EmployeeId = EmpId, + FirstName = "Bob", + LastName = "Northwind", + Email = EmpEmail, + DefaultRate = "160" + }); + + Json(server, "/api/employees/getSettingsDetails", "GET", new EmployeeSettings + { + EmployeeId = EmpId, + StartTime = "09:00", + EndTime = "17:00", + LunchBreakStart = "12:00", + LunchBreakEnd = "12:30", + TimeLessMinutes = 30, + TimezoneId = "Australia/Brisbane" + }); + } + + // ───────────────────────── Lookups ───────────────────────── + + private static void StubLookups(WireMockServer server) + { + Json(server, "/api/Timesheets/GetClientListForAddTimesheet", "GET", new List + { + new() { Value = ClientId, Text = ClientName } + }); + + Json(server, "/api/Projects/GetSelectListUsageDataProject", "GET", new List + { + new() { Value = ProjectId, DisplayText = ClientName, UseIteration = true }, + new() { Value = "N8000J", DisplayText = "Upgrade - Northwind", UseIteration = false } + }); + + Json(server, "/api/Timesheets/GetClientRate", "GET", new ClientRateResponse + { + EmpId = EmpId, + ClientId = ClientId, + ClientName = ClientName, + EmployeeName = EmpName, + Rate = 175m, + PrepaidRate = 150m, + ClientRateId = 900, + ExpiryDate = "2026-12-31", + Notes = "Standard rate" + }); + + RawJson(server, $"/api/v2/clients/{ClientId}/taxrates", "GET", "0.1"); + + Json(server, "/Crm/Appointments", "GET", new List + { + new() + { + Id = "appt-1", + Title = "Northwind Traders - Checkout API review", + Start = "2026-03-16T09:00:00", + End = "2026-03-16T10:00:00", + AllDay = false, + ClientId = ClientId, + ProjectId = ProjectId, + IterationId = 3402, + Editable = true, + TimeZoneOffsetInMinutes = 600 + } + }); + + Json(server, "/api/Timesheets/GetTimesheetLocation", "GET", new List + { + new() { LocationId = "SSW", LocationName = "SSW" }, + new() { LocationId = "Home", LocationName = "Home" } + }); + + Json(server, "/api/Timesheets/GetTimesheetCategories", "GET", new List + { + new() { CategoryId = "WEBDEV", CategoryName = "Web development", IsNonWorking = false }, + new() { CategoryId = "TRAIN", CategoryName = "Training", IsNonWorking = false } + }); + + Json(server, "/api/Timesheets/GetTimesheetBillableType", "GET", new List + { + new() { Value = "B", Text = "Billable" }, + new() { Value = "BPP", Text = "Prepaid" }, + new() { Value = "W", Text = "Write-off" } + }); + + Json(server, "/api/ProjectIteration/GetIterationsForAddTimesheet", "GET", new List + { + new() { IterationId = 3402, IterationName = "Checkout API", LastUsedDate = "2026-03-09" }, + new() { IterationId = 3403, IterationName = "Order history", LastUsedDate = "2026-03-13" } + }); + + Json(server, "/api/Timesheets/GetProjectsSummary", "GET", new List + { + new() + { + ProjectName = ClientName, + ClientID = ClientId, + ProjectID = ProjectId, + IsBillable = true, + Sum = 22.5m, + StartDate = "2026-03-01", + EndDate = "2026-03-31" + } + }); + } + + // ───────────────────────── Timesheets ───────────────────────── + + public static List Day(bool isSuggested = false) => + [ + new() + { + TimeId = TimesheetId, + EmpId = EmpId, + EmpName = EmpName, + Client = ClientName, + ClientId = ClientId, + Project = ClientName, + ProjectId = ProjectId, + Iteration = "Checkout API", + IterationId = null, + Category = "Web development", + 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", + BillableId = "B", + IsBillable = true, + Less = 0.5m, + TotalTime = 7.5m, + HasNotes = true, + IsSuggested = isSuggested, + IsLeave = false, + InvoiceId = null, + IsLocked = false + }, + new() + { + TimeId = SuggestedTimesheetId, + EmpId = EmpId, + EmpName = EmpName, + Client = ClientName, + ClientId = ClientId, + Project = ClientName, + ProjectId = ProjectId, + Iteration = "Order history", + IterationId = 3403, + Category = "Web development", + 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", + BillableId = "B", + IsBillable = true, + Less = 0m, + TotalTime = 1m, + HasNotes = true, + IsSuggested = true, + IsLeave = false, + IsLocked = false + } + ]; + + private static void StubTimesheets(WireMockServer server) + { + Json(server, "/api/Timesheets/GetTimesheetListViewModel", "GET", Day()); + + Json(server, "/api/timesheetSummary/GetTableSummarydata", "POST", new List + { + new() + { + TimeId = TimesheetId, + TimesheetDate = "2026-03-16", + EmpId = EmpId, + EmployeeName = EmpName, + BillableId = "B", + ClientId = ClientId, + ClientName = ClientName, + Description = "Product search", + ProjectId = ProjectId, + ProjectName = ClientName, + CategoryId = "WEBDEV", + CategoryName = "Web development", + Note = "Product search", + TotalHours = 7.5m, + SellPrice = 175m + } + }); + + RawJson(server, "/api/Timesheets/SaveTimesheet", "POST", string.Empty); + Status(server, "/api/Timesheets/RefreshSuggestedTimesheets", "GET", 200); + Status(server, $"/api/Timesheets/DeleteTimesheet/{TimesheetId}", "DELETE", 200); + + Json(server, "/api/Timesheets/AcceptSuggestedTimesheet", "POST", new TimesheetResponse + { + Success = true, + Message = "Accepted", + TimesheetId = TimesheetId + }); + } + + // ───────────────────────── Leave ───────────────────────── + + public static LeaveEntry LeaveEntryFixture() => new() + { + Id = LeaveId, + StartDate = "2026-04-01T00:00:00+10:00", + EndDate = "2026-04-01T23:59:00+10:00", + CreatedAt = "2026-03-01T09:00:00+10:00", + UpdatedAt = "2026-03-02T09:00:00+10:00", + Note = "Family day", + ApprovedBy = EmpEmail, + OptionalEmp = [EmpEmail], + RequestedEmpId = EmpId, + UserStartTime = "09:00", + UserEndTime = "18:00", + DaysAway = 1m, + AllDay = true, + Length = 1m, + LeaveStatus = 1, + LeaveType = new LeaveTypeInfo { Id = 1, Name = "Annual Leave", IsActive = true } + }; + + private static void StubLeave(WireMockServer server) + { + Json(server, "/api/leave/", "GET", new LeaveListResponse + { + CancelledCount = 1, + Leaves = new PaginatedList + { + PageNumber = 1, + PageSize = 10, + TotalItems = 1, + TotalPages = 1, + Items = [LeaveEntryFixture()] + } + }); + + Json(server, "/api/leave/types", "GET", new List + { + new() { Id = 1, Name = "Annual Leave", IsActive = true }, + new() { Id = 2, Name = "Sick Leave", IsActive = true } + }); + + Json(server, $"/api/leave/stats/{EmpId}", "GET", new LeaveStats + { + DaysSinceLastLeave = 42 + }); + + Status(server, "/api/leave/", "POST", 200); + Status(server, "/api/leave/", "PUT", 200); + + Json(server, "/api/leave/balances/status", "GET", new LeaveBalanceStatus + { + AsAtDate = new DateOnly(2026, 3, 1), + LastImportedAt = new DateTimeOffset(2026, 3, 2, 4, 30, 0, TimeSpan.Zero), + EmployeeCount = 12, + IsStale = false + }); + + Json(server, "/api/leave/balances/import", "POST", new ImportLeaveBalancesResult + { + AsAtDate = new DateOnly(2026, 3, 1), + Created = 1, + Updated = 2, + UnmatchedEmployees = ["Bobby Northwind"], + Warnings = ["Implausible balance for BOB: 999 days"] + }); + } + + // ───────────────────────── Accounting: invoices ───────────────────────── + + public static InvoiceHeader InvoiceFixture(int invoiceId = InvoiceId) => new() + { + InvoiceId = invoiceId, + ClientId = ClientId, + ProjectId = ProjectId, + CategoryId = "WEBDEV", + CurrencyId = "AUD", + ExchangeRate = 1, + InvoiceType = "P", + Batch = 1, + ClientRef = "PO-42", + DateStart = new DateTime(2026, 3, 1), + DateEnd = new DateTime(2026, 3, 31), + SubTotal = 1000m, + SellTotal = 1100m, + SalesTaxPct = 10, + SalesTaxAmt = 100m, + PaidAmt = 0m, + OSAmt = 1100m, + DateCreated = new DateTime(2026, 3, 31), + DateUpdated = new DateTime(2026, 3, 31), + Note = "Prepaid block", + Month = "2026-03", + PaymentTerms = 14, + IsRecurring = false, + IsLocked = false, + IsCreditNote = false, + OutstandingDays = 7, + RemainingPrepaidCredit = 400m + }; + + public static InvoiceTimesheet InvoiceTimesheetFixture(string billableId = "BPP") => new() + { + TimeId = TimesheetId, + EmpId = EmpId, + EmpName = EmpName, + ClientId = ClientId, + ClientName = ClientName, + ProjectId = ProjectId, + ProjectName = ClientName, + CategoryId = "WEBDEV", + BillableId = billableId, + DateCreated = new DateTime(2026, 3, 16), + TimeStart = "09:00", + TimeEnd = "17:00", + TotalTime = 7.5m, + Amount = 600m, + BillableAmount = 600m, + SellPrice = 80m, + SellTotal = 600m, + SalesTaxAmt = 60m, + SalesTaxPct = 10, + Notes = "Checkout API", + InvoiceId = InvoiceId + }; + + private static void StubAccountingInvoices(WireMockServer server) + { + Json(server, "/api/ClientInvoice/rangepaged", "GET", new PagedResponse + { + Total = 1, + Data = + [ + new() + { + InvoiceId = InvoiceId, + DateCreated = new DateTime(2026, 3, 31), + InvoiceType = "P", + ClientId = ClientId, + CoName = ClientName, + SellTotal = 1100m, + PaidAmt = 0m, + ExternalSyncStatus = 0 + } + ] + }); + + Json(server, $"/api/v2/ClientInvoice/{InvoiceId}", "GET", InvoiceFixture()); + + Json(server, $"/api/v2/ClientInvoice/{InvoiceId}/products", "GET", new List + { + new() + { + InvoiceProdId = 1, + InvoiceId = InvoiceId, + SkuId = "PREPAID-10", + SkuName = "Prepaid 10 hours", + ProdName = "Prepaid block", + CategoryId = "WEBDEV", + EmpId = EmpId, + Qty = 1, + SellAmt = 1000m, + SellTotal = 1000m, + SalesTaxAmt = 100m, + SalesTaxPct = 10, + Type = "S" + } + }); + + Json(server, "/api/v2/Timesheets/WithNames/Allocated", "GET", + new List { InvoiceTimesheetFixture() }); + Json(server, "/api/v2/Timesheets/WithNames/Writeoff", "GET", + new List { InvoiceTimesheetFixture("W") }); + Json(server, "/api/v2/Timesheets/WithNames/Unallocated", "GET", + new List { InvoiceTimesheetFixture("B") }); + + Json(server, $"/api/v2/ClientInvoice/{InvoiceId}/receipts", "GET", new List + { + new() + { + SaleReceiptId = ReceiptId, + InvoiceId = InvoiceId, + Paid = 1100m, + CoName = ClientName, + DateCreated = new DateTime(2026, 4, 1), + PaymentDate = new DateTime(2026, 4, 1), + EmpUpdated = EmpId, + Note = "EFT", + SaleReceiptStatus = "Allocated" + } + }); + + Json(server, $"/api/ClientInvoice/ClientID/{ClientId}", "GET", + new List { InvoiceFixture() }); + + Json(server, $"/api/ClientInvoice/GetByClientId/{ClientId}", "GET", new ClientInvoiceTable + { + Total = 1, + Invoices = [InvoiceFixture()] + }); + + Json(server, $"/api/ClientInvoice/UnpaidByClientID/{ClientId}", "GET", + new List { InvoiceFixture() }); + + server.Given(Request.Create().WithPath("/Reporting/GetPrepaidStatusReport").UsingGet()) + .AtPriority(BaselinePriority) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/pdf") + .WithBody("%PDF-1.4 northwind")); + } + + // ───────────────────────── Accounting: receipts ───────────────────────── + + private static void StubAccountingReceipts(WireMockServer server) + { + Json(server, "/api/receipting/PaidReceiptsPaged", "GET", new PagedResponse + { + Total = 1, + Data = + [ + new() + { + SaleReceiptId = ReceiptId, + ClientId = ClientId, + CoName = ClientName, + PaymentDate = new DateTime(2026, 4, 1), + DateCreated = new DateTime(2026, 4, 1), + EmpUpdated = EmpId, + Note = "EFT", + Bank = "Northwind Bank", + Unallocated = 0m, + PaidTotal = -1100m, + InvoiceIds = [InvoiceId], + SaleReceiptPaids = [], + SaleReceiptType = new ReceiptTypeInfo { Id = "EFT", TypeName = "EFT", TypeSign = "-" } + } + ] + }); + + Json(server, $"/api/Receipting/details/{ReceiptId}", "GET", new ReceiptDetailResponse + { + TotalPaid = 1100m, + InvoicesAmount = 1100m, + ContactPerson = "Bob Northwind", + PaymentMethods = [new ReceiptPaymentMethod { Id = "EFT", Name = "EFT", Sign = "-" }], + Receipt = new ReceiptDetail + { + SaleReceiptId = ReceiptId, + ReceiptType = "EFT", + ClientId = ClientId, + CoName = ClientName, + PaymentDate = new DateTime(2026, 4, 1), + IsNew = false, + ReceiptTotal = 1100m, + Note = "EFT", + SaleReceiptPaids = + [ + new() + { + InvoiceId = InvoiceId, + SaleReceiptId = ReceiptId, + InvoiceDate = new DateTime(2026, 3, 31), + PaymentDate = new DateTime(2026, 4, 1), + Total = 1100m, + AlreadyPaidAmt = 0m, + PaidAmt = 1100m, + Balance = 0m, + IsAllocated = true, + ClientId = ClientId, + CoName = ClientName + } + ], + OutstandingInvoices = [] + } + }); + + Json(server, $"/api/Receipting/ClientOutstanding/{ClientId}", "GET", new ClientOutstandingSummary + { + ClientId = ClientId, + CoName = ClientName, + ContactPerson = "Bob Northwind", + OutstandingInvoices = + [ + new() + { + InvoiceId = InvoiceId, + DateInvoiced = new DateTime(2026, 3, 31), + DueDate = new DateTime(2026, 4, 14), + Total = 1100m, + PaidAmt = 0m, + OsAmt = 1100m, + DaysOverdue = 7, + InvoiceType = "P" + } + ] + }); + + Json(server, $"/api/creditnote/by-client/{ClientId}", "GET", new List + { + new() + { + Id = 7, + Amount = 110m, + Note = "Goodwill credit", + CreditNoteDate = new DateTime(2026, 4, 2), + TaxRate = 10m, + IsLocked = false, + Paid = 0m, + SyncStatus = 0, + SyncDisplayName = "Not synced", + IsCreditingInvoice = true, + AssociatedInvoiceId = InvoiceId + } + }); + + Json(server, "/api/clients/OutstandingTime", "GET", new List + { + new() + { + ClientId = ClientId, + CoName = ClientName, + EmpId = EmpId, + FirstName = "Bob", + Surname = "Northwind", + Suburb = "Brisbane", + State = "QLD", + Os = 600m, + Billable = 600m, + DateUpdated = new DateTime(2026, 3, 16), + EarliestUnAllocatedTimesheetDate = new DateTime(2026, 3, 16) + } + }); + } + + // ───────────────────────── Accounting: products ───────────────────────── + + private static void StubAccountingProducts(WireMockServer server) + { + var sku = new ProductSkuRow + { + SkuId = "PREPAID-10", + SkuName = "Prepaid 10 hours", + ProductId = "PREPAID", + SellAmt = 1000m, + CostAmt = 0m, + RrpAmt = 1000m, + IsPrepaid = true, + DisplayOnWeb = false, + DateCreated = new DateTime(2026, 1, 1), + DateUpdated = new DateTime(2026, 1, 1) + }; + + Json(server, "/api/Product", "GET", new List + { + new() + { + ProductId = "PREPAID", + ProductName = "Prepaid block", + Head = "Prepaid", + Note = "Prepaid hours", + AllowDiscount = true, + DisplayOnWeb = false, + IsTraining = false, + DateCreated = new DateTime(2026, 1, 1), + DateUpdated = new DateTime(2026, 1, 1), + Skus = [sku] + } + }); + + Json(server, "/api/Product/PREPAID", "GET", new ProductRow + { + ProductId = "PREPAID", + ProductName = "Prepaid block", + Head = "Prepaid", + AllowDiscount = true, + DisplayOnWeb = false, + IsTraining = false, + DateCreated = new DateTime(2026, 1, 1), + DateUpdated = new DateTime(2026, 1, 1), + Skus = [sku] + }); + + Json(server, "/api/Product/All", "GET", new List { sku }); + + Json(server, $"/api/Product/GetDiscountsForClient/{ClientId}", "GET", new List + { + new() + { + ProductId = "PREPAID", + SkuId = "PREPAID-10", + ClientId = ClientId, + DiscountPct = 5m, + DiscountAmt = null, + DateStart = new DateTime(2026, 1, 1), + DateEnd = new DateTime(2026, 12, 31), + Note = "Volume discount" + } + }); + } + + private static void StubAccountingMisc(WireMockServer server) + { + Json(server, "/api/clients/GetClientRates", "GET", new ClientRateTable + { + Total = 1, + Rates = + [ + new() + { + ClientRateId = 900, + EmpId = EmpId, + EmployeeName = EmpName, + ClientId = ClientId, + ClientName = ClientName, + Rate = 175m, + PrepaidRate = 150m, + ExpiryDate = new DateTime(2026, 12, 31), + DateCreated = new DateTime(2026, 1, 1), + DateUpdated = new DateTime(2026, 1, 1), + Notes = "Standard rate" + } + ] + }); + + Json(server, "/api/recurring/invoices/", "GET", new PagedResponse + { + Total = 1, + Data = + [ + new() + { + Id = RecurringInvoiceId, + ClientId = ClientId, + ClientName = ClientName, + SellTotal = 1100m, + CountOfInv = 3, + Unit = RecurrenceUnit.Month, + Note = "Monthly retainer", + LastInvEndDate = new DateTime(2026, 3, 31), + IsActive = true, + CreatedOn = new DateTime(2026, 1, 1) + } + ] + }); + + Json(server, $"/api/recurring/invoices/{RecurringInvoiceId}", "GET", new RecurringInvoiceDetail + { + Id = RecurringInvoiceId, + ClientId = ClientId, + SellAmt = 1000m, + SellTaxAmt = 100m, + SellTotal = 1100m, + CountOfInv = 3, + FirstInvPeriod = 1, + SubsequentInvPeriod = 1, + LastInvPeriod = 1, + DateStart = new DateTime(2026, 1, 1), + DateEnd = new DateTime(2026, 12, 31), + LastInvEndDate = new DateTime(2026, 3, 31), + NextInvoicePeriodStart = new DateTime(2026, 4, 1), + NextInvoicePeriodEnd = new DateTime(2026, 4, 30), + CanGenerateNow = true, + Unit = RecurrenceUnit.Month, + Note = "Monthly retainer", + CreatedBy = EmpId, + CreatedOn = new DateTime(2026, 1, 1), + Products = + [ + new() + { + Id = 1, + RecurringInvoiceId = RecurringInvoiceId, + ProdId = "PREPAID", + ProdName = "Prepaid block", + SellAmt = 1000m, + SellTotal = 1000m, + SalesTaxPct = 10, + SalesTaxAmt = 100m, + Qty = 1, + CreatedBy = EmpId, + CreatedOn = new DateTime(2026, 1, 1) + } + ] + }); + } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/TestConfigService.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/TestConfigService.cs new file mode 100644 index 0000000..643ea2c --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/TestConfigService.cs @@ -0,0 +1,36 @@ +using SSW.TimePro.Cli.Infrastructure.Config; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// Fixed config for in-process harness runs. Deliberately never touches the filesystem, so a +/// harness case can never pick up the developer's own tenants, WFH days or feature flags. +/// +public sealed class TestConfigService : IConfigService +{ + private readonly TenantConfig _tenant; + + public TestConfigService(TenantConfig tenant) => _tenant = tenant; + + public GlobalConfig Global { get; init; } = new() + { + ActiveTenant = "northwind-test", + DefaultLocation = "Office", + WfhDays = [] + }; + + public List RepoMappings { get; init; } = []; + + public string ConfigDirectory => Path.Combine(Path.GetTempPath(), "tp-mcp-harness-inproc"); + public GlobalConfig LoadGlobalConfig() => Global; + public void SaveGlobalConfig(GlobalConfig config) { } + public TenantConfig? LoadTenantConfig(string tenantId) => _tenant; + public void SaveTenantConfig(TenantConfig config) { } + public void DeleteTenantConfig(string tenantId) { } + public TenantConfig? LoadActiveTenantConfig() => _tenant; + public void SetActiveTenantOverride(TenantConfig tenant) { } + public void ClearActiveTenantOverride() { } + public List ListTenants() => [_tenant]; + public List LoadRepoMappings() => RepoMappings; + public void SaveRepoMappings(List mappings) { } +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/WeekTokens.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/WeekTokens.cs new file mode 100644 index 0000000..a796f22 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/WeekTokens.cs @@ -0,0 +1,22 @@ +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// Replaces the current work week's five dates with tokens. WeekCoverageService derives its +/// window from the machine clock and has no injectable clock, so this — and only this — is the +/// declared normalisation the harness applies. +/// +public static class WeekTokens +{ + public static string Apply(string json) + { + var today = DateOnly.FromDateTime(DateTime.Today); + var monday = today.AddDays(-(int)today.DayOfWeek + (int)DayOfWeek.Monday); + if (today.DayOfWeek == DayOfWeek.Sunday) + monday = monday.AddDays(-7); + + for (var i = 0; i < 5; i++) + json = json.Replace(monday.AddDays(i).ToString("yyyy-MM-dd"), $"{{{{weekday{i}}}}}"); + + return json; + } +} diff --git a/tests/SSW.TimePro.Cli.Tests/Infrastructure/ConfigPathsTests.cs b/tests/SSW.TimePro.Cli.Tests/Infrastructure/ConfigPathsTests.cs new file mode 100644 index 0000000..0cfff86 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Tests/Infrastructure/ConfigPathsTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using SSW.TimePro.Cli.Infrastructure.Config; +using Xunit; + +namespace SSW.TimePro.Cli.Tests.Infrastructure; + +public class ConfigPathsTests +{ + [Fact] + public void Resolve_WithoutAnOverride_UsesTheUserProfile() + { + ConfigPaths.Resolve(null, "/home/bob") + .Should().Be(Path.Combine("/home/bob", ".config", "timepro-cli")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Resolve_WithABlankOverride_UsesTheUserProfile(string configDir) + { + ConfigPaths.Resolve(configDir, "/home/bob") + .Should().Be(Path.Combine("/home/bob", ".config", "timepro-cli")); + } + + [Fact] + public void Resolve_WithAnOverride_UsesIt() + { + var expected = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "tp-config")); + + ConfigPaths.Resolve(Path.Combine(Path.GetTempPath(), "tp-config"), "/home/bob") + .Should().Be(expected); + } + + [Fact] + public void Resolve_WithARelativeOverride_ReturnsAnAbsolutePath() + { + var resolved = ConfigPaths.Resolve("./tp-config", "/home/bob"); + + Path.IsPathRooted(resolved).Should().BeTrue(); + resolved.Should().EndWith("tp-config"); + } + + [Fact] + public void Resolve_WithATildeOverride_ExpandsTheHomeDirectory() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + ConfigPaths.Resolve("~/tp-config", "/home/bob") + .Should().Be(Path.Combine(home, "tp-config")); + } +} From fb24fc988545522659220d93a83f3bf475aba571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jernej=20Kavka=20=28JK=29=20=5BSSW=20=C2=B7=20Microsoft=20?= =?UTF-8?q?MVP=5D?= Date: Sun, 13 Sep 2026 00:54:23 +1000 Subject: [PATCH 2/2] fix(timesheets): shared create service for ts create and MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP create_timesheet built its own TimesheetRequest with no sell price, so a server that cannot derive one answered 400 and entries created through MCP could differ from CLI ones on the same inputs. TimesheetCreateService now owns every resolution a create needs — sell price from the client rate for the billable type, category from the repo mapping then recent entries, location from the WFH defaults, deducted minutes, iteration, and the read-back that turns an empty write response into the saved row. `ts create` and the MCP tool are adapters over it. The interactive missing-rate workflow stays in the command: PrepareAsync reports a missing rate and never writes one, and MCP answers with the `tp rate create` recovery command instead. The CreateTimesheet result golden changes deliberately. The tool returned the API's empty body serialised as literal `null`, which no client could use; it now returns the CLI's `{"success":true,"timesheetId":N,"timesheet":{...}}` document, serialised with the CLI's options so the two are byte-identical. The create row in McpCliParityTable is at parity with no permitted differences. The populated golden case now writes the note and start time of the day's existing row so the empty-body read-back has something to find. Closes #57 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 12 + scripts/e2e/mcp_smoke.py | 23 +- .../Features/Mcp/McpHostCommand.cs | 1 + .../Features/Mcp/Tools/TimesheetMcpTools.cs | 68 ++-- .../Features/Timesheets/CreateCommand.cs | 192 ++++-------- .../Timesheets/TimesheetCreateService.cs | 180 +++++++++++ src/SSW.TimePro.Cli/Program.cs | 1 + .../Features/McpTimesheetWriteTests.cs | 4 +- .../Mcp/Parity/CreateTimesheet.9.cli.json | 29 ++ .../Mcp/Parity/CreateTimesheet.9.mcp.json | 29 ++ .../Mcp/Tools/CreateTimesheet.populated.json | 30 +- .../Mcp/CliConsole.cs | 14 + .../Mcp/CliMcpCreatePayloadTests.cs | 263 ++++++++++++++++ .../Mcp/CliMcpParityTests.cs | 1 + .../Mcp/CliRunner.cs | 1 + .../Mcp/McpCliParityTable.cs | 30 +- .../Mcp/McpToolCatalog.cs | 4 +- .../Mcp/McpToolHost.cs | 3 +- .../Features/Timesheets/LessOptionTests.cs | 1 + .../Timesheets/TimesheetCreateServiceTests.cs | 293 ++++++++++++++++++ 20 files changed, 997 insertions(+), 182 deletions(-) create mode 100644 src/SSW.TimePro.Cli/Features/Timesheets/TimesheetCreateService.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateTimesheet.9.cli.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateTimesheet.9.mcp.json create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/CliConsole.cs create mode 100644 tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpCreatePayloadTests.cs create mode 100644 tests/SSW.TimePro.Cli.Tests/Features/Timesheets/TimesheetCreateServiceTests.cs diff --git a/AGENTS.md b/AGENTS.md index 0c80d2c..434b335 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,18 @@ resolving an iteration by name or ID) and `TimesheetAcceptService` owns accept; `ts accept` commands and the `UpdateTimesheet` / `AcceptSuggestedTimesheet` MCP tools are adapters over them. Never build a `TimesheetRequest` for an edit anywhere else. +`TimesheetCreateService` is the same prepare/apply pair for new entries and owns every resolution a +create needs: sell price from the client rate for the billable type, category from the repo mapping +then the last fortnight's entries, location from the WFH defaults, deducted minutes to hours, and the +read-back that turns an empty write response into the saved row. `ts create` and the +`CreateTimesheet` MCP tool are adapters over it; never build a `TimesheetRequest` for a create +elsewhere either. + +The API cannot price a row for a client with no active rate, so `PrepareAsync` stops and reports it +rather than resolving it. Creating a rate stays with the caller: `ts create` keeps its interactive +prompt, and MCP returns the `tp rate create` recovery command. Neither the service nor MCP ever +writes a rate. + `ts update` and `ts delete` refuse suggested entries locally (`tp ts accept ` first) rather than letting the API answer a bare 400; the MCP delete tool shares that check. Accept fails before the API call when the project uses iterations and none can be resolved, listing the available ones. diff --git a/scripts/e2e/mcp_smoke.py b/scripts/e2e/mcp_smoke.py index 2be1993..4d9770c 100755 --- a/scripts/e2e/mcp_smoke.py +++ b/scripts/e2e/mcp_smoke.py @@ -174,17 +174,6 @@ def call_tool(process, name, arguments): return payload -def create_tool(process, name, arguments): - try: - return call_tool(process, name, arguments) - except SmokeFailure as failure: - raise SmokeFailure( - f"{failure}\n" - " The MCP write path is not yet the CLI's: create_timesheet omits the sell price " - "the CLI resolves from the client rate, so TimePro rejects it." - ) from failure - - def assert_non_production(tp_command): info = subprocess.run( tp_command + ["tenant", "info", "--tenant", TENANT, "--json"], @@ -255,10 +244,9 @@ def main(): before = call_tool(process, "get_timesheets", {"date": date}) print(f" {len(before)} existing entries on {date}") - # The write goes through MCP on purpose. create_timesheet does not send a sell price, so - # a server that cannot derive one answers 400 and this gate stays red until the shared - # create orchestration lands. - create_tool( + # The write goes through MCP on purpose: create_timesheet and `tp ts create` share one + # service, so this is the gate on that service against a real server. + create_result = call_tool( process, "create_timesheet", { @@ -282,6 +270,11 @@ def main(): created = matches[0] created_id = created["timeId"] + reported_id = create_result.get("timesheetId") + if reported_id not in (None, created_id): + raise SmokeFailure( + f"create_timesheet reported id {reported_id} but the row read back is {created_id}" + ) for field, expected in ( ("clientId", CLIENT_ID), ("projectId", PROJECT_ID), diff --git a/src/SSW.TimePro.Cli/Features/Mcp/McpHostCommand.cs b/src/SSW.TimePro.Cli/Features/Mcp/McpHostCommand.cs index 1341c73..af8a90d 100644 --- a/src/SSW.TimePro.Cli/Features/Mcp/McpHostCommand.cs +++ b/src/SSW.TimePro.Cli/Features/Mcp/McpHostCommand.cs @@ -41,6 +41,7 @@ protected override async Task ExecuteAsync(CommandContext context, Settings builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/SSW.TimePro.Cli/Features/Mcp/Tools/TimesheetMcpTools.cs b/src/SSW.TimePro.Cli/Features/Mcp/Tools/TimesheetMcpTools.cs index 96585ca..71f0c16 100644 --- a/src/SSW.TimePro.Cli/Features/Mcp/Tools/TimesheetMcpTools.cs +++ b/src/SSW.TimePro.Cli/Features/Mcp/Tools/TimesheetMcpTools.cs @@ -2,10 +2,11 @@ using System.Text.Json; using System.Text.Json.Serialization; using ModelContextProtocol.Server; +using SSW.TimePro.Cli.Features.Rates; using SSW.TimePro.Cli.Features.Timesheets; using SSW.TimePro.Cli.Infrastructure.ApiClient; using SSW.TimePro.Cli.Infrastructure.Config; -using SSW.TimePro.Cli.Shared; +using SSW.TimePro.Cli.Infrastructure.Output; using SSW.TimePro.Cli.Shared.Models; namespace SSW.TimePro.Cli.Features.Mcp.Tools; @@ -15,6 +16,7 @@ public class TimesheetMcpTools { private readonly ITimeProApiClient _api; private readonly IConfigService _config; + private readonly TimesheetCreateService _creates; private readonly TimesheetUpdateService _updates; private readonly TimesheetAcceptService _accepts; @@ -28,11 +30,13 @@ public class TimesheetMcpTools public TimesheetMcpTools( ITimeProApiClient api, IConfigService config, + TimesheetCreateService creates, TimesheetUpdateService updates, TimesheetAcceptService accepts) { _api = api; _config = config; + _creates = creates; _updates = updates; _accepts = accepts; } @@ -96,37 +100,49 @@ public async Task CreateTimesheet( if (tenant?.EmployeeId is null) return """{"error": "Not logged in"}"""; - // Resolve location from WFH defaults if not specified - if (string.IsNullOrEmpty(location)) + try { - var global = _config.LoadGlobalConfig(); - var dateOnly = DateOnly.ParseExact(date, "yyyy-MM-dd"); - var dayName = dateOnly.DayOfWeek.ToString(); - location = global.WfhDays.Contains(dayName, StringComparer.OrdinalIgnoreCase) - ? "Home" : LocationResolver.Resolve(global.DefaultLocation); + var prepared = await _creates.PrepareAsync( + tenant.EmployeeId, + new TimesheetCreateOptions( + ClientId: clientId, + ProjectId: projectId, + Date: date, + Start: startTime, + End: endTime, + Description: description, + Location: location, + Category: categoryId, + IterationId: iterationId, + Billable: billableId), + ct); + + // Creating a rate is a deliberate act with its own approval, so the agent is told how + // to set one rather than having one written on its behalf. + if (prepared.NoActiveRate) + return NoActiveRateError(clientId); + + var result = await _creates.ApplyAsync(prepared.Plan!, ct); + + // Serialised with the CLI's options so both surfaces answer with the same document. + return OutputHelper.SerializeJson(result); } - else + catch (TimesheetValidationException ex) { - location = LocationResolver.Resolve(location); + return JsonSerializer.Serialize(new { error = ex.Message }, JsonOpts); } + } - var request = new TimesheetRequest - { - EmpId = tenant.EmployeeId, - ClientId = clientId, - ProjectId = projectId, - IterationId = iterationId, - DateCreated = date, - TimeStart = $"{date}T{startTime}:00", - TimeEnd = $"{date}T{endTime}:00", - Note = description, - LocationId = location, - CategoryId = categoryId, - BillableId = billableId - }; + private static string NoActiveRateError(string clientId) + { + var steps = RateResolver.BuildRecoveryOptions(clientId, new RateRecommendation(0m, 0m, RateSource.None)); - var response = await _api.CreateTimesheetAsync(request, ct); - return JsonSerializer.Serialize(response, JsonOpts); + return JsonSerializer.Serialize(new + { + error = $"No active rate for client '{clientId}' (expired or not set), so TimePro cannot price the entry. " + + "Set a rate using the recovery command below, then retry.", + recovery = new { reason = "no_active_rate", clientId, steps } + }, JsonOpts); } [McpServerTool] diff --git a/src/SSW.TimePro.Cli/Features/Timesheets/CreateCommand.cs b/src/SSW.TimePro.Cli/Features/Timesheets/CreateCommand.cs index 3b29886..8a0255a 100644 --- a/src/SSW.TimePro.Cli/Features/Timesheets/CreateCommand.cs +++ b/src/SSW.TimePro.Cli/Features/Timesheets/CreateCommand.cs @@ -1,10 +1,8 @@ using System.ComponentModel; -using System.Globalization; using SSW.TimePro.Cli.Features.Rates; using SSW.TimePro.Cli.Infrastructure.ApiClient; using SSW.TimePro.Cli.Infrastructure.Config; using SSW.TimePro.Cli.Infrastructure.Output; -using SSW.TimePro.Cli.Shared; using SSW.TimePro.Cli.Shared.Models; using Spectre.Console; using Spectre.Console.Cli; @@ -16,6 +14,7 @@ public class CreateCommand : AsyncCommand { private readonly ITimeProApiClient _api; private readonly IConfigService _config; + private readonly TimesheetCreateService _creates; public class Settings : CommandSettings { @@ -76,10 +75,11 @@ public class Settings : CommandSettings public bool Json { get; set; } } - public CreateCommand(ITimeProApiClient api, IConfigService config) + public CreateCommand(ITimeProApiClient api, IConfigService config, TimesheetCreateService creates) { _api = api; _config = config; + _creates = creates; } protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) @@ -97,12 +97,6 @@ protected override async Task ExecuteAsync(CommandContext context, Settings return 1; } - var date = settings.Date is not null - ? DateOnly.ParseExact(settings.Date, "yyyy-MM-dd", CultureInfo.InvariantCulture) - : DateOnly.FromDateTime(DateTime.Today); - - var startTime = settings.Start ?? "09:00"; - var endTime = settings.End ?? "17:00"; if (!LessOption.TryParse(settings.Less, out var lessMinutes, out var lessError)) { if (settings.Json) @@ -112,100 +106,48 @@ protected override async Task ExecuteAsync(CommandContext context, Settings return 1; } - var lessMins = lessMinutes ?? 0; - - // Resolve location from WFH defaults if not specified - var location = settings.Location; - if (string.IsNullOrEmpty(location)) - { - var global = _config.LoadGlobalConfig(); - var dayName = date.DayOfWeek.ToString(); - location = global.WfhDays.Contains(dayName, StringComparer.OrdinalIgnoreCase) - ? "Home" - : global.DefaultLocation; - } - - location = LocationResolver.Resolve(location ?? "SSW"); - - var billableId = settings.Billable ?? "B"; + var options = new TimesheetCreateOptions( + ClientId: settings.ClientId, + ProjectId: settings.ProjectId, + Date: settings.Date, + Start: settings.Start, + End: settings.End, + Description: settings.Description, + Location: settings.Location, + Category: settings.Category, + IterationId: settings.Iteration, + Billable: settings.Billable, + Less: lessMinutes); try { - // Resolve the sell price from the client rate. When no active rate exists the API rejects - // the timesheet (it can't derive a sell price), so offer to set one before continuing. - var rate = await _api.GetClientRateAsync( - tenant.EmployeeId, settings.ClientId, date, CancellationToken.None); - - var rateActive = rate?.Rate is not null - && (string.IsNullOrEmpty(rate.ExpiryDate) || RateResolver.IsActive(DateTime.Parse(rate.ExpiryDate), date)); + var prepared = await _creates.PrepareAsync(tenant.EmployeeId, options, cancellationToken); - decimal? sellPrice; - if (rateActive) - { - sellPrice = RateResolver.SellPriceFor(billableId, rate!.Rate ?? 0m, rate.PrepaidRate ?? 0m); - } - else + if (prepared.NoActiveRate) { - sellPrice = await ResolveMissingRateAsync( - tenant.EmployeeId, settings.ClientId, billableId, + var sellPrice = await ResolveMissingRateAsync( + tenant.EmployeeId, settings.ClientId, settings.Billable ?? "B", settings.Yes, settings.Json, settings.RejectIfRateExpired, cancellationToken); if (sellPrice is null) return 1; // rejected, user cancelled, or non-interactive with no rate set + + prepared = await _creates.PrepareAsync( + tenant.EmployeeId, options with { SellPrice = sellPrice }, cancellationToken); } - // Auto-resolve category when not explicitly specified - var categoryId = settings.Category - ?? ResolveCategoryFromRepoMapping(settings.ClientId, settings.ProjectId) - ?? await ResolveCategoryFromRecentTimesheets( - tenant.EmployeeId, settings.ClientId, settings.ProjectId, date); + var plan = prepared.Plan!; - var request = new TimesheetRequest - { - EmpId = tenant.EmployeeId, - ClientId = settings.ClientId, - ProjectId = settings.ProjectId, - IterationId = settings.Iteration, - DateCreated = date.ToString("yyyy-MM-dd"), - TimeStart = $"{date:yyyy-MM-dd}T{startTime}:00", - TimeEnd = $"{date:yyyy-MM-dd}T{endTime}:00", - TimeLess = lessMins > 0 ? lessMins / 60m : null, - Note = settings.Description, - LocationId = location, - CategoryId = categoryId, - BillableId = billableId, - SellPrice = sellPrice, - }; - - // Show preview if (!settings.Json) - { - AnsiConsole.MarkupLine("[bold]Creating timesheet:[/]"); - AnsiConsole.MarkupLine($" Date: {date:yyyy-MM-dd} ({date:dddd})"); - AnsiConsole.MarkupLine($" Client: {Markup.Escape(settings.ClientId)}"); - AnsiConsole.MarkupLine($" Project: {Markup.Escape(settings.ProjectId)}"); - AnsiConsole.MarkupLine($" Time: {startTime} - {endTime}"); - AnsiConsole.MarkupLine($" Location: {Markup.Escape(location ?? "?")}"); - AnsiConsole.MarkupLine($" Billable: {request.BillableId}"); - if (categoryId is not null) - AnsiConsole.MarkupLine($" Category: {Markup.Escape(categoryId)}{(settings.Category is null ? " [dim](auto-resolved)[/]" : "")}"); - if (sellPrice is not null) - AnsiConsole.MarkupLine($" Sell price: ${sellPrice:F2}"); - if (!string.IsNullOrEmpty(settings.Description)) - AnsiConsole.MarkupLine($" Notes: {Markup.Escape(settings.Description)}"); - AnsiConsole.WriteLine(); - } + WritePreview(plan, settings); - if (!settings.Yes && !settings.Json) - { - if (!AnsiConsole.Confirm("Create this timesheet?")) - return 1; - } + if (!settings.Yes && !settings.Json && !AnsiConsole.Confirm("Create this timesheet?")) + return 1; - var response = await _api.CreateTimesheetAsync(request, CancellationToken.None); + var result = await _creates.ApplyAsync(plan, cancellationToken); - if (response is { Success: false }) + if (!result.Success) { - var failure = response.Message ?? "Failed to create timesheet"; + var failure = result.Message ?? "Failed to create timesheet"; if (settings.Json) OutputHelper.WriteJsonError(failure); else @@ -213,18 +155,6 @@ protected override async Task ExecuteAsync(CommandContext context, Settings return 1; } - // SaveTimesheet usually answers with an empty body, so read the row back to report its id. - var created = response?.TimesheetId is not null - ? await TimesheetLookup.ReadByIdAsync(_api, tenant.EmployeeId, date, response.TimesheetId.Value, cancellationToken) - : await TimesheetLookup.ReadCreatedAsync(_api, tenant.EmployeeId, date, request, cancellationToken); - - var result = new TimesheetWriteResult - { - TimesheetId = response?.TimesheetId ?? created?.TimeId, - Message = response?.Message, - Timesheet = created - }; - if (settings.Json) OutputHelper.WriteJson(result); else @@ -232,6 +162,14 @@ protected override async Task ExecuteAsync(CommandContext context, Settings return 0; } + catch (TimesheetValidationException ex) + { + if (settings.Json) + OutputHelper.WriteJsonError(ex.Message); + else + OutputHelper.WriteError(ex.Message); + return 1; + } catch (ApiException ex) { var detail = ApiErrorParser.ExtractDetail(ex.ResponseBody); @@ -260,6 +198,25 @@ protected override async Task ExecuteAsync(CommandContext context, Settings } } + private static void WritePreview(TimesheetCreatePlan plan, Settings settings) + { + var request = plan.Request; + AnsiConsole.MarkupLine("[bold]Creating timesheet:[/]"); + AnsiConsole.MarkupLine($" Date: {plan.Date:yyyy-MM-dd} ({plan.Date:dddd})"); + AnsiConsole.MarkupLine($" Client: {Markup.Escape(request.ClientId)}"); + AnsiConsole.MarkupLine($" Project: {Markup.Escape(request.ProjectId)}"); + AnsiConsole.MarkupLine($" Time: {plan.Start} - {plan.End}"); + AnsiConsole.MarkupLine($" Location: {Markup.Escape(request.LocationId ?? "?")}"); + AnsiConsole.MarkupLine($" Billable: {request.BillableId}"); + if (request.CategoryId is not null) + AnsiConsole.MarkupLine($" Category: {Markup.Escape(request.CategoryId)}{(settings.Category is null ? " [dim](auto-resolved)[/]" : "")}"); + if (request.SellPrice is not null) + AnsiConsole.MarkupLine($" Sell price: ${request.SellPrice:F2}"); + if (!string.IsNullOrEmpty(request.Note)) + AnsiConsole.MarkupLine($" Notes: {Markup.Escape(request.Note)}"); + AnsiConsole.WriteLine(); + } + /// /// No active rate exists for the client (expired or never set), which the API needs to derive a /// sell price. Mirrors the Angular timesheet form: interactively offer to create a rate inline @@ -311,41 +268,4 @@ await _api.SaveClientRateAsync(new SaveClientRateModel OutputHelper.WriteSuccess($"Rate created: ${rate:F2}."); return RateResolver.SellPriceFor(billableId, rate, prepaid); } - - /// - /// Look up categoryId from repo-mappings.json for the given client/project. - /// - private string? ResolveCategoryFromRepoMapping(string clientId, string projectId) - { - var mappings = _config.LoadRepoMappings(); - var match = mappings.FirstOrDefault(m => - string.Equals(m.ClientId, clientId, StringComparison.OrdinalIgnoreCase) && - string.Equals(m.ProjectId, projectId, StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrEmpty(m.CategoryId)); - return match?.CategoryId; - } - - /// - /// Look up categoryId from recent timesheets for the same employee + client + project. - /// Searches the past 14 days for a match. - /// - private async Task ResolveCategoryFromRecentTimesheets( - string empId, string clientId, string projectId, DateOnly aroundDate) - { - var filter = new TimesheetSummaryFilter - { - StartDate = aroundDate.AddDays(-14).ToString("yyyy-MM-dd"), - EndDate = aroundDate.ToString("yyyy-MM-dd"), - EmployeeIds = [empId], - ClientIds = [clientId], - ProjectIds = [projectId] - }; - - var entries = await _api.QueryTimesheetsAsync(filter, CancellationToken.None); - return entries - .Where(e => !string.IsNullOrEmpty(e.CategoryId)) - .OrderByDescending(e => e.TimesheetDate) - .FirstOrDefault() - ?.CategoryId; - } } diff --git a/src/SSW.TimePro.Cli/Features/Timesheets/TimesheetCreateService.cs b/src/SSW.TimePro.Cli/Features/Timesheets/TimesheetCreateService.cs new file mode 100644 index 0000000..94f44da --- /dev/null +++ b/src/SSW.TimePro.Cli/Features/Timesheets/TimesheetCreateService.cs @@ -0,0 +1,180 @@ +using SSW.TimePro.Cli.Features.Rates; +using SSW.TimePro.Cli.Infrastructure.ApiClient; +using SSW.TimePro.Cli.Infrastructure.Config; +using SSW.TimePro.Cli.Shared; +using SSW.TimePro.Cli.Shared.Models; + +namespace SSW.TimePro.Cli.Features.Timesheets; + +public sealed record TimesheetCreateOptions( + string ClientId, + string ProjectId, + string? Date = null, + string? Start = null, + string? End = null, + string? Description = null, + string? Location = null, + string? Category = null, + int? IterationId = null, + string? Billable = null, + int? Less = null, + decimal? SellPrice = null); + +public sealed record TimesheetCreatePlan( + TimesheetRequest Request, + DateOnly Date, + string Start, + string End); + +/// +/// A prepared create, or the one outcome the service refuses to resolve on its own: a client with +/// no active rate. is null only in that case — everything else throws. +/// +public sealed record TimesheetCreatePreparation(TimesheetCreatePlan? Plan) +{ + public bool NoActiveRate => Plan is null; +} + +/// +/// Builds a complete SaveTimesheet payload for a new entry: sell price from the client rate, +/// category from the repo mapping or recent entries, location from the WFH defaults, break time in +/// hours. The API rejects a row it cannot price, so a missing rate stops the create — creating a +/// rate is the caller's explicit decision, never a side effect of logging time. +/// +public sealed class TimesheetCreateService +{ + private const int RecentCategoryDays = 14; + + private readonly ITimeProApiClient _api; + private readonly IConfigService _config; + + public TimesheetCreateService(ITimeProApiClient api, IConfigService config) + { + _api = api; + _config = config; + } + + public async Task PrepareAsync( + string employeeId, + TimesheetCreateOptions options, + CancellationToken ct = default) + { + if (options.Less is < 0) + throw new TimesheetValidationException("Break/less time must be zero or greater"); + + var date = options.Date is not null + ? TimesheetLookup.ParseDate(options.Date) + : DateOnly.FromDateTime(DateTime.Today); + + var start = options.Start ?? "09:00"; + var end = options.End ?? "17:00"; + var billableId = options.Billable ?? "B"; + var less = options.Less ?? 0; + + var sellPrice = options.SellPrice ?? await ResolveSellPriceAsync( + employeeId, options.ClientId, billableId, date, ct); + if (sellPrice is null) + return new TimesheetCreatePreparation(null); + + var request = new TimesheetRequest + { + EmpId = employeeId, + ClientId = options.ClientId, + ProjectId = options.ProjectId, + IterationId = options.IterationId, + DateCreated = date.ToString("yyyy-MM-dd"), + TimeStart = $"{date:yyyy-MM-dd}T{start}:00", + TimeEnd = $"{date:yyyy-MM-dd}T{end}:00", + TimeLess = less > 0 ? less / 60m : null, + Note = options.Description, + LocationId = ResolveLocation(options.Location, date), + CategoryId = options.Category + ?? CategoryFromRepoMapping(options.ClientId, options.ProjectId) + ?? await CategoryFromRecentEntriesAsync(employeeId, options.ClientId, options.ProjectId, date, ct), + BillableId = billableId, + SellPrice = sellPrice, + }; + + return new TimesheetCreatePreparation(new TimesheetCreatePlan(request, date, start, end)); + } + + public async Task ApplyAsync(TimesheetCreatePlan plan, CancellationToken ct = default) + { + var response = await _api.CreateTimesheetAsync(plan.Request, ct); + + if (response is { Success: false }) + { + return new TimesheetWriteResult + { + Success = false, + TimesheetId = response.TimesheetId, + Message = response.Message ?? "Failed to create timesheet" + }; + } + + // SaveTimesheet usually answers with an empty body, so the row is read back, never re-sent. + var created = response?.TimesheetId is not null + ? await TimesheetLookup.ReadByIdAsync(_api, plan.Request.EmpId, plan.Date, response.TimesheetId.Value, ct) + : await TimesheetLookup.ReadCreatedAsync(_api, plan.Request.EmpId, plan.Date, plan.Request, ct); + + return new TimesheetWriteResult + { + TimesheetId = response?.TimesheetId ?? created?.TimeId, + Message = response?.Message, + Timesheet = created + }; + } + + private async Task ResolveSellPriceAsync( + string employeeId, string clientId, string billableId, DateOnly date, CancellationToken ct) + { + var rate = await _api.GetClientRateAsync(employeeId, clientId, date, ct); + var active = rate?.Rate is not null + && (string.IsNullOrEmpty(rate.ExpiryDate) || RateResolver.IsActive(DateTime.Parse(rate.ExpiryDate), date)); + + return active + ? RateResolver.SellPriceFor(billableId, rate!.Rate ?? 0m, rate.PrepaidRate ?? 0m) + : null; + } + + private string ResolveLocation(string? requested, DateOnly date) + { + if (!string.IsNullOrEmpty(requested)) + return LocationResolver.Resolve(requested); + + var global = _config.LoadGlobalConfig(); + var location = global.WfhDays.Contains(date.DayOfWeek.ToString(), StringComparer.OrdinalIgnoreCase) + ? "Home" + : global.DefaultLocation; + + return LocationResolver.Resolve(location ?? "SSW"); + } + + private string? CategoryFromRepoMapping(string clientId, string projectId) => + _config.LoadRepoMappings() + .FirstOrDefault(m => + string.Equals(m.ClientId, clientId, StringComparison.OrdinalIgnoreCase) + && string.Equals(m.ProjectId, projectId, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(m.CategoryId)) + ?.CategoryId; + + private async Task CategoryFromRecentEntriesAsync( + string employeeId, string clientId, string projectId, DateOnly date, CancellationToken ct) + { + var filter = new TimesheetSummaryFilter + { + StartDate = date.AddDays(-RecentCategoryDays).ToString("yyyy-MM-dd"), + EndDate = date.ToString("yyyy-MM-dd"), + EmployeeIds = [employeeId], + ClientIds = [clientId], + ProjectIds = [projectId] + }; + + var entries = await _api.QueryTimesheetsAsync(filter, ct); + return entries + .Where(e => !string.IsNullOrEmpty(e.CategoryId)) + .OrderByDescending(e => e.TimesheetDate) + .FirstOrDefault() + ?.CategoryId; + } +} diff --git a/src/SSW.TimePro.Cli/Program.cs b/src/SSW.TimePro.Cli/Program.cs index 9f11a17..489adf3 100644 --- a/src/SSW.TimePro.Cli/Program.cs +++ b/src/SSW.TimePro.Cli/Program.cs @@ -79,6 +79,7 @@ services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); +services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/SSW.TimePro.Cli.Integration/Features/McpTimesheetWriteTests.cs b/tests/SSW.TimePro.Cli.Integration/Features/McpTimesheetWriteTests.cs index 663dec0..962f8e2 100644 --- a/tests/SSW.TimePro.Cli.Integration/Features/McpTimesheetWriteTests.cs +++ b/tests/SSW.TimePro.Cli.Integration/Features/McpTimesheetWriteTests.cs @@ -129,9 +129,11 @@ public async Task DeleteTimesheet_WhenEntryIsReal_Deletes() private TimesheetMcpTools CreateTools() { var updates = new TimesheetUpdateService(ApiClient); + var config = new StubConfigService(TestTenant); return new TimesheetMcpTools( ApiClient, - new StubConfigService(TestTenant), + config, + new TimesheetCreateService(ApiClient, config), updates, new TimesheetAcceptService(ApiClient, updates)); } diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateTimesheet.9.cli.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateTimesheet.9.cli.json new file mode 100644 index 0000000..ad9d3a3 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateTimesheet.9.cli.json @@ -0,0 +1,29 @@ +{ + "success": true, + "timesheet": { + "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 + }, + "timesheetId": 4242 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateTimesheet.9.mcp.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateTimesheet.9.mcp.json new file mode 100644 index 0000000..ad9d3a3 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateTimesheet.9.mcp.json @@ -0,0 +1,29 @@ +{ + "success": true, + "timesheet": { + "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 + }, + "timesheetId": 4242 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.populated.json b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.populated.json index 19765bd..ad9d3a3 100644 --- a/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.populated.json +++ b/tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.populated.json @@ -1 +1,29 @@ -null +{ + "success": true, + "timesheet": { + "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 + }, + "timesheetId": 4242 +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/CliConsole.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/CliConsole.cs new file mode 100644 index 0000000..ef79b89 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/CliConsole.cs @@ -0,0 +1,14 @@ +using Xunit; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// One xUnit collection for the harness classes that run the real command tree through +/// . It redirects the process-wide Console.Out, so two of them +/// running concurrently would capture each other's output. +/// +[CollectionDefinition(Collection)] +public sealed class CliConsole +{ + public const string Collection = "cli-console"; +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpCreatePayloadTests.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpCreatePayloadTests.cs new file mode 100644 index 0000000..44bf8d6 --- /dev/null +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpCreatePayloadTests.cs @@ -0,0 +1,263 @@ +using System.Text.Json; +using FluentAssertions; +using SSW.TimePro.Cli.Infrastructure.Config; +using SSW.TimePro.Cli.Shared.Models; +using Xunit; + +namespace SSW.TimePro.Cli.Integration.Mcp; + +/// +/// Byte-for-byte SaveTimesheet payload equality between ts create and the +/// CreateTimesheet tool. Equal results would not prove equal writes, so this compares the +/// request bodies and the traffic that produced them. +/// +[Collection(CliConsole.Collection)] +public class CliMcpCreatePayloadTests : TestBase +{ + private const string Client = NorthwindApi.ClientId; + private const string Project = NorthwindApi.ProjectId; + private const string Date = NorthwindApi.AnyDate; + private const string SaveRoute = "/api/Timesheets/SaveTimesheet"; + private const string RateRoute = "/api/Timesheets/GetClientRate"; + private const string SaveRateRoute = "/api/Timesheets/SaveClientRate"; + private const string SummaryRoute = "/api/timesheetSummary/GetTableSummarydata"; + + private CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData("B", 175)] + [InlineData("BPP", 150)] + [InlineData("W", 175)] + public async Task Create_PricesEachBillableTypeFromTheClientRate(string billable, int sellPrice) + { + var config = McpToolCatalog.Config(WireMock.Url!); + + var cli = await CliPayloadAsync(config, CreateArgs("--billable", billable)); + var mcp = await McpPayloadAsync(config, h => h.Timesheets.CreateTimesheet( + Client, Project, Date, description: "Product search", billableId: billable, + iterationId: 3402, ct: Ct)); + + mcp.Should().Be(cli); + Field(cli, "sellPrice").GetDecimal().Should().Be(sellPrice); + Field(cli, "iterationID").GetInt32().Should().Be(3402); + } + + [Fact] + public async Task Create_TakesTheCategoryFromTheRepoMappingBeforeRecentEntries() + { + var config = McpToolCatalog.Config(WireMock.Url!); + + var cli = await CliPayloadAsync(config, CreateArgs()); + var cliRequests = Paths(); + var mcp = await McpPayloadAsync(config, Invoke()); + + mcp.Should().Be(cli); + Field(cli, "categoryID").GetString().Should().Be("WEBDEV"); + cliRequests.Should().NotContain(SummaryRoute); + Paths().Should().NotContain(SummaryRoute); + } + + [Fact] + public async Task Create_FallsBackToTheCategoryOnRecentEntries() + { + var config = new TestConfigService(McpToolCatalog.Tenant(WireMock.Url!)); + + var cli = await CliPayloadAsync(config, CreateArgs(), RecentCategory("TRAIN")); + var mcp = await McpPayloadAsync(config, Invoke(), RecentCategory("TRAIN")); + + mcp.Should().Be(cli); + Field(cli, "categoryID").GetString().Should().Be("TRAIN"); + Paths().Should().Contain(SummaryRoute); + } + + [Fact] + public async Task Create_PrefersTheExplicitCategory() + { + var config = McpToolCatalog.Config(WireMock.Url!); + + var cli = await CliPayloadAsync(config, CreateArgs("--category", "TRAIN")); + var mcp = await McpPayloadAsync(config, h => h.Timesheets.CreateTimesheet( + Client, Project, Date, description: "Product search", categoryId: "TRAIN", + iterationId: 3402, ct: Ct)); + + mcp.Should().Be(cli); + Field(cli, "categoryID").GetString().Should().Be("TRAIN"); + } + + [Fact] + public async Task Create_ResolvesTheExplicitLocationAlias() + { + var config = McpToolCatalog.Config(WireMock.Url!); + + var cli = await CliPayloadAsync(config, CreateArgs("--location", "At Home")); + var mcp = await McpPayloadAsync(config, h => h.Timesheets.CreateTimesheet( + Client, Project, Date, description: "Product search", location: "At Home", + iterationId: 3402, ct: Ct)); + + mcp.Should().Be(cli); + Field(cli, "locationID").GetString().Should().Be("Home"); + } + + [Fact] + public async Task Create_UsesTheWfhDefaultForTheDayWhenNoLocationIsGiven() + { + var config = new TestConfigService(McpToolCatalog.Tenant(WireMock.Url!)) + { + Global = new GlobalConfig { DefaultLocation = "Office", WfhDays = ["Monday"] } + }; + + var cli = await CliPayloadAsync(config, CreateArgs()); + var mcp = await McpPayloadAsync(config, Invoke()); + + mcp.Should().Be(cli); + Field(cli, "locationID").GetString().Should().Be("Home"); + } + + /// + /// The MCP tool has no deducted-minutes argument, so this dimension is CLI-only until the + /// tool schema changes. + /// + [Fact] + public async Task Create_SendsDeductedMinutesAsHours() + { + var cli = await CliPayloadAsync(McpToolCatalog.Config(WireMock.Url!), CreateArgs("--less", "90")); + + Field(cli, "timeLess").GetDecimal().Should().Be(1.5m); + } + + [Fact] + public async Task Create_WhenTheRateHasExpired_WritesNothingOnEitherSurface() + { + var config = McpToolCatalog.Config(WireMock.Url!); + + NorthwindApi.StubAll(WireMock); + ExpiredRate(WireMock); + var cli = await CliRunner.RunAsync( + [.. CreateArgs(), "--reject-if-rate-expired"], ApiClient, config, Ct); + + cli.ExitCode.Should().Be(1); + AssertNoWrites(); + + WireMock.Reset(); + NorthwindApi.StubAll(WireMock); + ExpiredRate(WireMock); + var mcp = await new McpToolHost(ApiClient, config).Timesheets.CreateTimesheet( + Client, Project, Date, description: "Product search", iterationId: 3402, ct: Ct); + + AssertNoWrites(); + using var doc = JsonDocument.Parse(mcp); + doc.RootElement.GetProperty("error").GetString().Should().Contain("No active rate"); + doc.RootElement.GetProperty("recovery").GetProperty("reason").GetString() + .Should().Be("no_active_rate"); + doc.RootElement.GetProperty("recovery").GetProperty("steps")[0].GetProperty("command") + .GetString().Should().StartWith($"tp rate create --client {Client}"); + } + + [Fact] + public async Task Create_WhenTheApiAnswersWithAnEmptyBody_ReadsBackInsteadOfCreatingAgain() + { + var config = McpToolCatalog.Config(WireMock.Url!); + + await CliPayloadAsync(config, CreateArgs()); + Paths().Count(p => p == SaveRoute).Should().Be(1); + + var mcp = await McpPayloadAsync(config, Invoke()); + Paths().Count(p => p == SaveRoute).Should().Be(1); + mcp.Should().NotBeNull(); + } + + [Fact] + public async Task Create_ReturnsTheSavedEntryOnBothSurfaces() + { + var config = McpToolCatalog.Config(WireMock.Url!); + + NorthwindApi.StubAll(WireMock); + var cli = await CliRunner.RunAsync(CreateArgs(), ApiClient, config, Ct); + WireMock.Reset(); + NorthwindApi.StubAll(WireMock); + var mcp = await new McpToolHost(ApiClient, config).Timesheets.CreateTimesheet( + Client, Project, Date, description: "Product search", iterationId: 3402, ct: Ct); + + Golden.Canonicalize(mcp).Should().Be(Golden.Canonicalize(cli.Stdout)); + using var doc = JsonDocument.Parse(mcp); + doc.RootElement.GetProperty("timesheetId").GetInt32().Should().Be(NorthwindApi.TimesheetId); + doc.RootElement.GetProperty("timesheet").GetProperty("timeId").GetInt32() + .Should().Be(NorthwindApi.TimesheetId); + } + + private static string[] CreateArgs(params string[] extra) => + [ + "ts", "create", "--client", Client, "--project", Project, "--date", Date, + "--description", "Product search", "--iteration", "3402", "--yes", "--json", + .. extra + ]; + + private Func> Invoke() => h => h.Timesheets.CreateTimesheet( + Client, Project, Date, description: "Product search", iterationId: 3402, ct: Ct); + + private static Action RecentCategory(string categoryId) => + server => NorthwindApi.Json(server, SummaryRoute, "POST", new List + { + new() + { + TimeId = NorthwindApi.TimesheetId, + TimesheetDate = Date, + CategoryId = categoryId, + SellPrice = 175m + } + }, NorthwindApi.OverridePriority); + + private static void ExpiredRate(WireMock.Server.WireMockServer server) => + NorthwindApi.Json(server, RateRoute, "GET", new ClientRateResponse + { + EmpId = NorthwindApi.EmpId, + ClientId = Client, + Rate = 175m, + PrepaidRate = 150m, + ExpiryDate = "2026-01-31" + }, NorthwindApi.OverridePriority); + + private async Task CliPayloadAsync( + IConfigService config, string[] args, Action? arrange = null) + { + WireMock.Reset(); + NorthwindApi.StubAll(WireMock); + arrange?.Invoke(WireMock); + + var result = await CliRunner.RunAsync(args, ApiClient, config, Ct); + result.ExitCode.Should().Be(0, result.Stderr); + + return SavedPayload(); + } + + private async Task McpPayloadAsync( + IConfigService config, + Func> invoke, + Action? arrange = null) + { + WireMock.Reset(); + NorthwindApi.StubAll(WireMock); + arrange?.Invoke(WireMock); + + await invoke(new McpToolHost(ApiClient, config)); + + return SavedPayload(); + } + + private string SavedPayload() => + Golden.Canonicalize(WireMock.LogEntries + .Single(e => e.RequestMessage!.AbsolutePath == SaveRoute) + .RequestMessage!.Body!); + + private List Paths() => + WireMock.LogEntries.Select(e => e.RequestMessage!.AbsolutePath).ToList(); + + private void AssertNoWrites() + { + Paths().Should().NotContain(SaveRoute); + Paths().Should().NotContain(SaveRateRoute); + } + + private static JsonElement Field(string payload, string name) => + JsonDocument.Parse(payload).RootElement.GetProperty(name); +} diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpParityTests.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpParityTests.cs index 9fb6dfb..cc945f9 100644 --- a/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpParityTests.cs +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpParityTests.cs @@ -8,6 +8,7 @@ namespace SSW.TimePro.Cli.Integration.Mcp; /// Runs a CLI command and its MCP tool against identical, freshly reset Northwind state and /// compares the complete parsed documents plus the HTTP traffic each produced. /// +[Collection(CliConsole.Collection)] public class CliMcpParityTests : TestBase { public static TheoryData ExecutableRows() diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/CliRunner.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/CliRunner.cs index b494dd8..084f2a5 100644 --- a/tests/SSW.TimePro.Cli.Integration/Mcp/CliRunner.cs +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/CliRunner.cs @@ -29,6 +29,7 @@ public static async Task RunAsync( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs index a31d525..8bbeafd 100644 --- a/tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs @@ -191,10 +191,38 @@ public static class McpCliParityTable ExpectParity = true }, + new("CreateTimesheet", "ts create") + { + CliArgs = ["ts", "create", "--client", NorthwindApi.ClientId, "--project", NorthwindApi.ProjectId, + "--date", Date, "--description", "Product search", "--iteration", "3402", + "--yes", "--json"], + InvokeTool = (h, ct) => h.Timesheets.CreateTimesheet( + NorthwindApi.ClientId, NorthwindApi.ProjectId, Date, + description: "Product search", iterationId: 3402, ct: ct), + ExpectParity = true, + ExpectedRequests = + [ + new("GET", "/api/Timesheets/GetClientRate"), + new("POST", "/api/Timesheets/SaveTimesheet") + { + BodyContains = + [ + "\"sellPrice\":175", + "\"categoryID\":\"WEBDEV\"", + "\"locationID\":\"SSW\"", + "\"iterationID\":3402", + "\"billableID\":\"B\"", + "\"timeStart\":\"2026-03-16T09:00:00\"", + "\"salesTaxPct\":0.1" + ] + } + ], + ForbiddenRequests = [new("POST", "/api/Timesheets/SaveClientRate")] + }, + // ───────── 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("CreateTimesheet", "ts create") { Note = "No shared create orchestration yet (#57)." }, new("ListIterations", "iteration list") { Note = "Separate projections." }, new("GetSuggestedTimesheets", "ts suggest") { Note = "Separate projections." }, new("SearchClients", "client search") { Note = "Separate projections." }, diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs index e9ff96b..26ac1cd 100644 --- a/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs @@ -33,8 +33,10 @@ private static IReadOnlyList BuildPopulated() => EmptyBody = "[]" }, + // 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( - Client, Project, Date, description: "Checkout API", iterationId: 3402, ct: ct)) + Client, Project, Date, description: "Product search", iterationId: 3402, ct: ct)) { PrimaryRoute = "/api/Timesheets/SaveTimesheet", PrimaryMethod = "POST" diff --git a/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolHost.cs b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolHost.cs index 31b3027..09b06e1 100644 --- a/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolHost.cs +++ b/tests/SSW.TimePro.Cli.Integration/Mcp/McpToolHost.cs @@ -15,8 +15,9 @@ public sealed class McpToolHost public McpToolHost(ITimeProApiClient api, IConfigService config) { var updates = new TimesheetUpdateService(api); + var creates = new TimesheetCreateService(api, config); - Timesheets = new TimesheetMcpTools(api, config, updates, new TimesheetAcceptService(api, updates)); + Timesheets = new TimesheetMcpTools(api, config, creates, updates, new TimesheetAcceptService(api, updates)); Lookups = new LookupMcpTools(api, config); Leave = new LeaveMcpTools(api, config, new LeaveCreateService(api), new LeaveUpdateService(api)); Accounting = new AccountingMcpTools(api, config, new LeaveBalanceImportService(api)); diff --git a/tests/SSW.TimePro.Cli.Tests/Features/Timesheets/LessOptionTests.cs b/tests/SSW.TimePro.Cli.Tests/Features/Timesheets/LessOptionTests.cs index a0ed1f4..959a3ed 100644 --- a/tests/SSW.TimePro.Cli.Tests/Features/Timesheets/LessOptionTests.cs +++ b/tests/SSW.TimePro.Cli.Tests/Features/Timesheets/LessOptionTests.cs @@ -87,6 +87,7 @@ public async Task Command_WhenLessIsInvalid_WritesEnvelopeAndSkipsTheApi( var services = new ServiceCollection(); services.AddSingleton(api); services.AddSingleton(config); + services.AddSingleton(); services.AddSingleton(); var app = new CommandApp(new TypeRegistrar(services)); diff --git a/tests/SSW.TimePro.Cli.Tests/Features/Timesheets/TimesheetCreateServiceTests.cs b/tests/SSW.TimePro.Cli.Tests/Features/Timesheets/TimesheetCreateServiceTests.cs new file mode 100644 index 0000000..84368ee --- /dev/null +++ b/tests/SSW.TimePro.Cli.Tests/Features/Timesheets/TimesheetCreateServiceTests.cs @@ -0,0 +1,293 @@ +using FluentAssertions; +using NSubstitute; +using SSW.TimePro.Cli.Features.Timesheets; +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.Timesheets; + +public class TimesheetCreateServiceTests +{ + private const string Emp = "TST"; + private const string Client = "NWIND"; + private const string Project = "1I776Q"; + private const string Date = "2026-03-16"; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData("B", 175)] + [InlineData("BPP", 150)] + [InlineData("W", 175)] + public async Task Prepare_PricesTheEntryFromTheClientRate(string billable, int expected) + { + var api = ApiWithRate(); + var service = new TimesheetCreateService(api, Config()); + + var prepared = await service.PrepareAsync(Emp, Options(Billable: billable), Ct); + + prepared.Plan!.Request.SellPrice.Should().Be(expected); + } + + [Fact] + public async Task Prepare_WhenTheRateHasExpired_ReportsNoActiveRateAndWritesNothing() + { + var api = ApiWithRate(expiry: "2026-01-31"); + var service = new TimesheetCreateService(api, Config()); + + var prepared = await service.PrepareAsync(Emp, Options(), Ct); + + prepared.NoActiveRate.Should().BeTrue(); + prepared.Plan.Should().BeNull(); + await api.DidNotReceive().SaveClientRateAsync( + Arg.Any(), Arg.Any()); + await api.DidNotReceive().CreateTimesheetAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Prepare_WhenTheClientHasNoRate_ReportsNoActiveRate() + { + var api = Substitute.For(); + api.GetClientRateAsync(Emp, Client, new DateOnly(2026, 3, 16), Arg.Any()) + .Returns((ClientRateResponse?)null); + var service = new TimesheetCreateService(api, Config()); + + var prepared = await service.PrepareAsync(Emp, Options(), Ct); + + prepared.NoActiveRate.Should().BeTrue(); + } + + [Fact] + public async Task Prepare_WhenASellPriceIsSupplied_SkipsTheRateLookup() + { + var api = ApiWithRate(expiry: "2026-01-31"); + var service = new TimesheetCreateService(api, Config()); + + var prepared = await service.PrepareAsync(Emp, Options(SellPrice: 42m), Ct); + + prepared.Plan!.Request.SellPrice.Should().Be(42m); + await api.DidNotReceive().GetClientRateAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Prepare_TakesTheCategoryFromTheRepoMapping() + { + var api = ApiWithRate(); + var service = new TimesheetCreateService(api, Config(mappedCategory: "WEBDEV")); + + var prepared = await service.PrepareAsync(Emp, Options(), Ct); + + prepared.Plan!.Request.CategoryId.Should().Be("WEBDEV"); + await api.DidNotReceive().QueryTimesheetsAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Prepare_WithoutAMapping_TakesTheCategoryFromTheMostRecentEntry() + { + var api = ApiWithRate(); + api.QueryTimesheetsAsync(Arg.Any(), Arg.Any()) + .Returns([ + new TimesheetSummaryEntry { TimeId = 1, TimesheetDate = "2026-03-02", CategoryId = "TRAIN" }, + new TimesheetSummaryEntry { TimeId = 2, TimesheetDate = "2026-03-10", CategoryId = "WEBDEV" } + ]); + var service = new TimesheetCreateService(api, Config()); + + var prepared = await service.PrepareAsync(Emp, Options(), Ct); + + prepared.Plan!.Request.CategoryId.Should().Be("WEBDEV"); + } + + [Fact] + public async Task Prepare_PrefersTheExplicitCategory() + { + var api = ApiWithRate(); + var service = new TimesheetCreateService(api, Config(mappedCategory: "WEBDEV")); + + var prepared = await service.PrepareAsync(Emp, Options(Category: "TRAIN"), Ct); + + prepared.Plan!.Request.CategoryId.Should().Be("TRAIN"); + } + + [Fact] + public async Task Prepare_ResolvesTheLocationAlias() + { + var api = ApiWithRate(); + var service = new TimesheetCreateService(api, Config()); + + var prepared = await service.PrepareAsync(Emp, Options(Location: "At Client"), Ct); + + prepared.Plan!.Request.LocationId.Should().Be("Client"); + } + + [Theory] + [InlineData("Monday", "Home")] + [InlineData("Tuesday", "SSW")] + public async Task Prepare_WithoutALocation_UsesTheWfhDefaultsForTheDay(string wfhDay, string expected) + { + var api = ApiWithRate(); + var config = Config(); + config.LoadGlobalConfig().Returns(new GlobalConfig { DefaultLocation = "Office", WfhDays = [wfhDay] }); + var service = new TimesheetCreateService(api, config); + + var prepared = await service.PrepareAsync(Emp, Options(), Ct); + + prepared.Plan!.Request.LocationId.Should().Be(expected); + } + + [Theory] + [InlineData(90, 1.5)] + [InlineData(30, 0.5)] + [InlineData(0, null)] + [InlineData(null, null)] + public async Task Prepare_SendsDeductedMinutesAsHours(int? minutes, double? expectedHours) + { + var api = ApiWithRate(); + var service = new TimesheetCreateService(api, Config()); + + var prepared = await service.PrepareAsync(Emp, Options(Less: minutes), Ct); + + prepared.Plan!.Request.TimeLess.Should().Be((decimal?)expectedHours); + } + + [Fact] + public async Task Prepare_WhenDeductedMinutesAreNegative_Throws() + { + var service = new TimesheetCreateService(ApiWithRate(), Config()); + + await Assert.ThrowsAsync( + () => service.PrepareAsync(Emp, Options(Less: -1), Ct)); + } + + [Fact] + public async Task Prepare_DefaultsTheWorkdayTimes() + { + var api = ApiWithRate(); + var service = new TimesheetCreateService(api, Config()); + + var prepared = await service.PrepareAsync(Emp, Options(), Ct); + + prepared.Plan!.Request.TimeStart.Should().Be("2026-03-16T09:00:00"); + prepared.Plan.Request.TimeEnd.Should().Be("2026-03-16T17:00:00"); + prepared.Plan.Request.DateCreated.Should().Be(Date); + prepared.Plan.Request.TimeId.Should().BeNull(); + } + + [Fact] + public async Task Apply_WhenTheApiAnswersWithAnEmptyBody_ReadsTheRowBackWithoutCreatingAgain() + { + var api = ApiWithRate(); + api.CreateTimesheetAsync(Arg.Any(), Arg.Any()) + .Returns((TimesheetResponse?)null); + api.GetTimesheetsAsync(Emp, new DateOnly(2026, 3, 16), Arg.Any()) + .Returns([CreatedRow(4242)]); + var service = new TimesheetCreateService(api, Config()); + var prepared = await service.PrepareAsync(Emp, Options(Description: "Product search"), Ct); + + var result = await service.ApplyAsync(prepared.Plan!, Ct); + + result.Success.Should().BeTrue(); + result.TimesheetId.Should().Be(4242); + result.Timesheet!.TimeId.Should().Be(4242); + await api.Received(1).CreateTimesheetAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Apply_WhenTheApiReturnsAnId_ReadsThatRowBack() + { + var api = ApiWithRate(); + api.CreateTimesheetAsync(Arg.Any(), Arg.Any()) + .Returns(new TimesheetResponse { Success = true, TimesheetId = 4242 }); + api.GetTimesheetsAsync(Emp, new DateOnly(2026, 3, 16), Arg.Any()) + .Returns([CreatedRow(4242)]); + var service = new TimesheetCreateService(api, Config()); + var prepared = await service.PrepareAsync(Emp, Options(Description: "Something else"), Ct); + + var result = await service.ApplyAsync(prepared.Plan!, Ct); + + result.TimesheetId.Should().Be(4242); + result.Timesheet!.TimeId.Should().Be(4242); + } + + [Fact] + public async Task Apply_WhenTheApiReportsFailure_ReturnsItWithoutReadingBack() + { + var api = ApiWithRate(); + api.CreateTimesheetAsync(Arg.Any(), Arg.Any()) + .Returns(new TimesheetResponse { Success = false, Message = "Duplicate entry" }); + var service = new TimesheetCreateService(api, Config()); + var prepared = await service.PrepareAsync(Emp, Options(), Ct); + + var result = await service.ApplyAsync(prepared.Plan!, Ct); + + result.Success.Should().BeFalse(); + result.Message.Should().Be("Duplicate entry"); + result.Timesheet.Should().BeNull(); + await api.DidNotReceive().GetTimesheetsAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + private static TimesheetCreateOptions Options( + string? Location = null, + string? Category = null, + string? Billable = null, + string? Description = null, + int? Less = null, + decimal? SellPrice = null) => + new(Client, Project, Date, Description: Description, Location: Location, Category: Category, + IterationId: 3402, Billable: Billable, Less: Less, SellPrice: SellPrice); + + private static ITimeProApiClient ApiWithRate(string? expiry = "2026-12-31") + { + var api = Substitute.For(); + api.GetClientRateAsync(Emp, Client, new DateOnly(2026, 3, 16), Arg.Any()) + .Returns(new ClientRateResponse + { + EmpId = Emp, + ClientId = Client, + Rate = 175m, + PrepaidRate = 150m, + ExpiryDate = expiry + }); + api.QueryTimesheetsAsync(Arg.Any(), Arg.Any()) + .Returns([]); + return api; + } + + private static IConfigService Config(string? mappedCategory = null) + { + var config = Substitute.For(); + config.LoadGlobalConfig().Returns(new GlobalConfig { DefaultLocation = "Office", WfhDays = [] }); + config.LoadRepoMappings().Returns(mappedCategory is null + ? [] + : [ + new RepoMappingEntry + { + PathPattern = "~/code/traders-app", + ClientId = Client, + ProjectId = Project, + CategoryId = mappedCategory + } + ]); + return config; + } + + private static TimesheetItem CreatedRow(int timeId) => new() + { + TimeId = timeId, + EmpId = Emp, + ClientId = Client, + ProjectId = Project, + Notes = "Product search", + Date = "2026-03-16T00:00:00", + StartTime = "2026-03-16T09:00:00", + EndTime = "2026-03-16T17:00:00", + BillableId = "B", + IsSuggested = false + }; +}