feat(platform): checkpoint current implementation state
This commit is contained in:
@@ -1,631 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||
import { getAvailableAssistantTools } from "../router/assistant-tool-policy.js";
|
||||
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
|
||||
|
||||
function getToolNames(
|
||||
permissions: PermissionKeyValue[],
|
||||
userRole: SystemRole = SystemRole.ADMIN,
|
||||
) {
|
||||
return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name);
|
||||
}
|
||||
|
||||
describe("assistant tool policy", () => {
|
||||
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
|
||||
const withoutAdvanced = getToolNames([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
PermissionKey.VIEW_COSTS,
|
||||
]);
|
||||
const withAdvanced = getToolNames([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
]);
|
||||
|
||||
expect(withoutAdvanced).not.toContain("find_best_project_resource");
|
||||
expect(withAdvanced).toContain("find_best_project_resource");
|
||||
expect(withAdvanced).toContain("get_chargeability_report");
|
||||
expect(withAdvanced).toContain("get_resource_computation_graph");
|
||||
expect(withAdvanced).toContain("get_project_computation_graph");
|
||||
});
|
||||
|
||||
it("keeps user self-service tools available to plain authenticated users", () => {
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(userNames).toContain("get_current_user");
|
||||
expect(userNames).toContain("get_dashboard_layout");
|
||||
expect(userNames).toContain("save_dashboard_layout");
|
||||
expect(userNames).toContain("get_favorite_project_ids");
|
||||
expect(userNames).toContain("toggle_favorite_project");
|
||||
expect(userNames).toContain("get_column_preferences");
|
||||
expect(userNames).toContain("set_column_preferences");
|
||||
expect(userNames).toContain("get_mfa_status");
|
||||
expect(userNames).toContain("list_notifications");
|
||||
expect(userNames).toContain("get_unread_notification_count");
|
||||
expect(userNames).toContain("list_tasks");
|
||||
expect(userNames).toContain("get_task_counts");
|
||||
expect(userNames).toContain("create_reminder");
|
||||
expect(userNames).toContain("list_reminders");
|
||||
expect(userNames).toContain("update_reminder");
|
||||
expect(userNames).toContain("delete_reminder");
|
||||
});
|
||||
|
||||
it("keeps admin-only user tools hidden from non-admin roles", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("list_users");
|
||||
expect(adminNames).toContain("get_active_user_count");
|
||||
expect(adminNames).toContain("create_user");
|
||||
expect(adminNames).toContain("set_user_password");
|
||||
expect(adminNames).toContain("update_user_role");
|
||||
expect(adminNames).toContain("update_user_name");
|
||||
expect(adminNames).toContain("link_user_resource");
|
||||
expect(adminNames).toContain("auto_link_users_by_email");
|
||||
expect(adminNames).toContain("set_user_permissions");
|
||||
expect(adminNames).toContain("reset_user_permissions");
|
||||
expect(adminNames).toContain("get_effective_user_permissions");
|
||||
expect(adminNames).toContain("disable_user_totp");
|
||||
|
||||
expect(managerNames).not.toContain("list_users");
|
||||
expect(managerNames).not.toContain("create_user");
|
||||
expect(managerNames).not.toContain("set_user_permissions");
|
||||
expect(managerNames).not.toContain("disable_user_totp");
|
||||
expect(userNames).not.toContain("list_users");
|
||||
expect(userNames).not.toContain("get_active_user_count");
|
||||
expect(userNames).not.toContain("create_user");
|
||||
expect(userNames).not.toContain("set_user_password");
|
||||
expect(userNames).not.toContain("update_user_role");
|
||||
expect(userNames).not.toContain("update_user_name");
|
||||
expect(userNames).not.toContain("link_user_resource");
|
||||
expect(userNames).not.toContain("auto_link_users_by_email");
|
||||
expect(userNames).not.toContain("set_user_permissions");
|
||||
expect(userNames).not.toContain("reset_user_permissions");
|
||||
expect(userNames).not.toContain("get_effective_user_permissions");
|
||||
expect(userNames).not.toContain("disable_user_totp");
|
||||
});
|
||||
|
||||
it("keeps assignable users and manager notification lifecycle tools behind manager/admin role", () => {
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(managerNames).toContain("list_assignable_users");
|
||||
expect(managerNames).toContain("create_notification");
|
||||
expect(managerNames).toContain("create_task_for_user");
|
||||
expect(managerNames).toContain("assign_task");
|
||||
expect(managerNames).toContain("send_broadcast");
|
||||
expect(managerNames).toContain("list_broadcasts");
|
||||
expect(managerNames).toContain("get_broadcast_detail");
|
||||
expect(adminNames).toContain("list_assignable_users");
|
||||
expect(adminNames).toContain("create_task_for_user");
|
||||
expect(adminNames).toContain("send_broadcast");
|
||||
expect(userNames).not.toContain("list_assignable_users");
|
||||
expect(userNames).not.toContain("create_notification");
|
||||
expect(userNames).not.toContain("create_task_for_user");
|
||||
expect(userNames).not.toContain("assign_task");
|
||||
expect(userNames).not.toContain("send_broadcast");
|
||||
expect(userNames).not.toContain("list_broadcasts");
|
||||
expect(userNames).not.toContain("get_broadcast_detail");
|
||||
});
|
||||
|
||||
it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
|
||||
const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]);
|
||||
|
||||
expect(names).not.toContain("find_best_project_resource");
|
||||
expect(names).not.toContain("get_chargeability_report");
|
||||
expect(names).not.toContain("get_resource_computation_graph");
|
||||
expect(names).not.toContain("get_project_computation_graph");
|
||||
});
|
||||
|
||||
it("keeps controller-grade readmodels hidden from plain users while allowing controller roles", () => {
|
||||
const controllerNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.CONTROLLER);
|
||||
const userNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(controllerNames).toContain("query_change_history");
|
||||
expect(controllerNames).toContain("get_entity_timeline");
|
||||
expect(controllerNames).toContain("search_by_skill");
|
||||
expect(controllerNames).toContain("export_resources_csv");
|
||||
expect(controllerNames).toContain("export_projects_csv");
|
||||
expect(controllerNames).toContain("list_audit_log_entries");
|
||||
expect(controllerNames).toContain("get_audit_log_entry");
|
||||
expect(controllerNames).toContain("get_audit_log_timeline");
|
||||
expect(controllerNames).toContain("get_audit_activity_summary");
|
||||
expect(controllerNames).toContain("get_chargeability_report");
|
||||
expect(controllerNames).toContain("get_resource_computation_graph");
|
||||
expect(controllerNames).toContain("get_project_computation_graph");
|
||||
expect(userNames).not.toContain("query_change_history");
|
||||
expect(userNames).not.toContain("get_entity_timeline");
|
||||
expect(userNames).not.toContain("search_by_skill");
|
||||
expect(userNames).not.toContain("export_resources_csv");
|
||||
expect(userNames).not.toContain("export_projects_csv");
|
||||
expect(userNames).not.toContain("list_audit_log_entries");
|
||||
expect(userNames).not.toContain("get_audit_log_entry");
|
||||
expect(userNames).not.toContain("get_audit_log_timeline");
|
||||
expect(userNames).not.toContain("get_audit_activity_summary");
|
||||
expect(userNames).not.toContain("get_chargeability_report");
|
||||
expect(userNames).not.toContain("get_resource_computation_graph");
|
||||
expect(userNames).not.toContain("get_project_computation_graph");
|
||||
});
|
||||
|
||||
it("keeps entity-scoped comment tools available to plain authenticated users", () => {
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(userNames).toContain("list_comments");
|
||||
expect(userNames).toContain("create_comment");
|
||||
expect(userNames).toContain("resolve_comment");
|
||||
});
|
||||
|
||||
it("keeps planning read tools behind the explicit planning permission", () => {
|
||||
const userWithoutPlanning = getToolNames([], SystemRole.USER);
|
||||
const userWithPlanning = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER);
|
||||
|
||||
expect(userWithoutPlanning).not.toContain("list_allocations");
|
||||
expect(userWithoutPlanning).not.toContain("list_demands");
|
||||
expect(userWithoutPlanning).not.toContain("list_blueprints");
|
||||
expect(userWithoutPlanning).not.toContain("get_blueprint");
|
||||
expect(userWithoutPlanning).not.toContain("list_clients");
|
||||
expect(userWithoutPlanning).not.toContain("list_roles");
|
||||
expect(userWithoutPlanning).not.toContain("list_management_levels");
|
||||
expect(userWithoutPlanning).not.toContain("list_utilization_categories");
|
||||
expect(userWithoutPlanning).not.toContain("check_resource_availability");
|
||||
expect(userWithoutPlanning).not.toContain("find_capacity");
|
||||
expect(userWithoutPlanning).not.toContain("get_staffing_suggestions");
|
||||
expect(userWithoutPlanning).not.toContain("find_best_project_resource");
|
||||
expect(userWithPlanning).toContain("list_allocations");
|
||||
expect(userWithPlanning).toContain("list_demands");
|
||||
expect(userWithPlanning).toContain("list_blueprints");
|
||||
expect(userWithPlanning).toContain("get_blueprint");
|
||||
expect(userWithPlanning).toContain("list_clients");
|
||||
expect(userWithPlanning).toContain("list_roles");
|
||||
expect(userWithPlanning).toContain("list_management_levels");
|
||||
expect(userWithPlanning).toContain("list_utilization_categories");
|
||||
expect(userWithPlanning).toContain("check_resource_availability");
|
||||
expect(userWithPlanning).toContain("find_capacity");
|
||||
expect(userWithPlanning).not.toContain("get_staffing_suggestions");
|
||||
expect(userWithPlanning).not.toContain("find_best_project_resource");
|
||||
});
|
||||
|
||||
it("keeps cost-aware staffing assistant tools behind cost and advanced gates", () => {
|
||||
const planningOnly = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER);
|
||||
const planningAndCosts = getToolNames([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
PermissionKey.VIEW_COSTS,
|
||||
], SystemRole.USER);
|
||||
const planningCostsAndAdvanced = getToolNames([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(planningOnly).not.toContain("get_staffing_suggestions");
|
||||
expect(planningOnly).not.toContain("find_best_project_resource");
|
||||
expect(planningAndCosts).toContain("get_staffing_suggestions");
|
||||
expect(planningAndCosts).not.toContain("find_best_project_resource");
|
||||
expect(planningCostsAndAdvanced).toContain("get_staffing_suggestions");
|
||||
expect(planningCostsAndAdvanced).toContain("find_best_project_resource");
|
||||
});
|
||||
|
||||
it("keeps controller-only project and dashboard reads hidden from plain users", () => {
|
||||
const controllerNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.CONTROLLER);
|
||||
const userNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(controllerNames).toContain("search_projects");
|
||||
expect(controllerNames).toContain("get_project");
|
||||
expect(controllerNames).toContain("get_statistics");
|
||||
expect(controllerNames).toContain("get_dashboard_detail");
|
||||
expect(controllerNames).toContain("get_skill_gaps");
|
||||
expect(controllerNames).toContain("get_project_health");
|
||||
expect(controllerNames).toContain("get_budget_forecast");
|
||||
expect(controllerNames).toContain("get_budget_status");
|
||||
expect(controllerNames).toContain("get_shoring_ratio");
|
||||
expect(userNames).not.toContain("search_projects");
|
||||
expect(userNames).not.toContain("get_project");
|
||||
expect(userNames).not.toContain("get_statistics");
|
||||
expect(userNames).not.toContain("get_dashboard_detail");
|
||||
expect(userNames).not.toContain("get_skill_gaps");
|
||||
expect(userNames).not.toContain("get_project_health");
|
||||
expect(userNames).not.toContain("get_budget_forecast");
|
||||
expect(userNames).not.toContain("get_budget_status");
|
||||
expect(userNames).not.toContain("get_shoring_ratio");
|
||||
});
|
||||
|
||||
it("keeps legacy controller-only analysis and report tools hidden from plain users", () => {
|
||||
const controllerNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
], SystemRole.CONTROLLER);
|
||||
const userNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(controllerNames).toContain("detect_anomalies");
|
||||
expect(controllerNames).toContain("get_insights_summary");
|
||||
expect(controllerNames).toContain("run_report");
|
||||
expect(controllerNames).toContain("lookup_rate");
|
||||
expect(controllerNames).toContain("simulate_scenario");
|
||||
expect(controllerNames).toContain("generate_project_narrative");
|
||||
expect(controllerNames).toContain("list_rate_cards");
|
||||
expect(controllerNames).toContain("resolve_rate");
|
||||
expect(userNames).not.toContain("detect_anomalies");
|
||||
expect(userNames).not.toContain("get_insights_summary");
|
||||
expect(userNames).not.toContain("run_report");
|
||||
expect(userNames).not.toContain("lookup_rate");
|
||||
expect(userNames).not.toContain("simulate_scenario");
|
||||
expect(userNames).not.toContain("generate_project_narrative");
|
||||
expect(userNames).not.toContain("list_rate_cards");
|
||||
expect(userNames).not.toContain("resolve_rate");
|
||||
});
|
||||
|
||||
it("keeps cost-sensitive legacy rate tools hidden without viewCosts", () => {
|
||||
const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER);
|
||||
const controllerWithCosts = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER);
|
||||
|
||||
expect(controllerWithoutCosts).not.toContain("list_rate_cards");
|
||||
expect(controllerWithoutCosts).not.toContain("resolve_rate");
|
||||
expect(controllerWithCosts).toContain("list_rate_cards");
|
||||
expect(controllerWithCosts).toContain("resolve_rate");
|
||||
});
|
||||
|
||||
it("requires both controller role and advanced assistant access for timeline detail tools", () => {
|
||||
const controllerWithAdvanced = getToolNames([
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.CONTROLLER);
|
||||
const controllerWithoutAdvanced = getToolNames([], SystemRole.CONTROLLER);
|
||||
const userWithAdvanced = getToolNames([
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(controllerWithAdvanced).toContain("get_timeline_entries_view");
|
||||
expect(controllerWithAdvanced).toContain("get_timeline_holiday_overlays");
|
||||
expect(controllerWithAdvanced).toContain("get_project_timeline_context");
|
||||
expect(controllerWithAdvanced).toContain("preview_project_shift");
|
||||
expect(controllerWithoutAdvanced).not.toContain("get_timeline_entries_view");
|
||||
expect(controllerWithoutAdvanced).not.toContain("get_timeline_holiday_overlays");
|
||||
expect(controllerWithoutAdvanced).not.toContain("get_project_timeline_context");
|
||||
expect(controllerWithoutAdvanced).not.toContain("preview_project_shift");
|
||||
expect(userWithAdvanced).not.toContain("get_timeline_entries_view");
|
||||
expect(userWithAdvanced).not.toContain("get_timeline_holiday_overlays");
|
||||
expect(userWithAdvanced).not.toContain("get_project_timeline_context");
|
||||
expect(userWithAdvanced).not.toContain("preview_project_shift");
|
||||
});
|
||||
|
||||
it("exposes self-service timeline tools to authenticated users without advanced assistant access", () => {
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
const viewerNames = getToolNames([], SystemRole.VIEWER);
|
||||
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
|
||||
|
||||
expect(userNames).toContain("get_my_timeline_entries_view");
|
||||
expect(userNames).toContain("get_my_timeline_holiday_overlays");
|
||||
expect(viewerNames).toContain("get_my_timeline_entries_view");
|
||||
expect(viewerNames).toContain("get_my_timeline_holiday_overlays");
|
||||
expect(controllerNames).toContain("get_my_timeline_entries_view");
|
||||
expect(controllerNames).toContain("get_my_timeline_holiday_overlays");
|
||||
});
|
||||
|
||||
it("keeps timeline write parity tools behind manager/admin role, manageAllocations, and advanced assistant access", () => {
|
||||
const managerNames = getToolNames([
|
||||
PermissionKey.MANAGE_ALLOCATIONS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([
|
||||
PermissionKey.MANAGE_ALLOCATIONS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.USER);
|
||||
const missingAdvancedNames = getToolNames([
|
||||
PermissionKey.MANAGE_ALLOCATIONS,
|
||||
], SystemRole.MANAGER);
|
||||
|
||||
expect(managerNames).toContain("update_timeline_allocation_inline");
|
||||
expect(managerNames).toContain("apply_timeline_project_shift");
|
||||
expect(managerNames).toContain("quick_assign_timeline_resource");
|
||||
expect(managerNames).toContain("batch_quick_assign_timeline_resources");
|
||||
expect(managerNames).toContain("batch_shift_timeline_allocations");
|
||||
expect(userNames).not.toContain("update_timeline_allocation_inline");
|
||||
expect(userNames).not.toContain("apply_timeline_project_shift");
|
||||
expect(userNames).not.toContain("quick_assign_timeline_resource");
|
||||
expect(userNames).not.toContain("batch_quick_assign_timeline_resources");
|
||||
expect(userNames).not.toContain("batch_shift_timeline_allocations");
|
||||
expect(missingAdvancedNames).not.toContain("update_timeline_allocation_inline");
|
||||
expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource");
|
||||
});
|
||||
|
||||
it("keeps estimate lifecycle mutations behind manager/admin role and their router permissions", () => {
|
||||
const managerProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.MANAGER);
|
||||
const managerAllocationNames = getToolNames([PermissionKey.MANAGE_ALLOCATIONS], SystemRole.MANAGER);
|
||||
const userProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.USER);
|
||||
|
||||
expect(managerProjectNames).toContain("create_estimate");
|
||||
expect(managerProjectNames).toContain("clone_estimate");
|
||||
expect(managerProjectNames).toContain("update_estimate_draft");
|
||||
expect(managerProjectNames).toContain("submit_estimate_version");
|
||||
expect(managerProjectNames).toContain("approve_estimate_version");
|
||||
expect(managerProjectNames).toContain("create_estimate_revision");
|
||||
expect(managerProjectNames).toContain("create_estimate_export");
|
||||
expect(managerProjectNames).toContain("generate_estimate_weekly_phasing");
|
||||
expect(managerProjectNames).toContain("update_estimate_commercial_terms");
|
||||
expect(managerProjectNames).not.toContain("create_estimate_planning_handoff");
|
||||
expect(managerAllocationNames).toContain("create_estimate_planning_handoff");
|
||||
expect(managerAllocationNames).not.toContain("create_estimate");
|
||||
expect(userProjectNames).not.toContain("create_estimate");
|
||||
expect(userProjectNames).not.toContain("clone_estimate");
|
||||
expect(userProjectNames).not.toContain("update_estimate_draft");
|
||||
expect(userProjectNames).not.toContain("submit_estimate_version");
|
||||
expect(userProjectNames).not.toContain("approve_estimate_version");
|
||||
expect(userProjectNames).not.toContain("create_estimate_revision");
|
||||
expect(userProjectNames).not.toContain("create_estimate_export");
|
||||
expect(userProjectNames).not.toContain("generate_estimate_weekly_phasing");
|
||||
expect(userProjectNames).not.toContain("update_estimate_commercial_terms");
|
||||
expect(userProjectNames).not.toContain("create_estimate_planning_handoff");
|
||||
});
|
||||
|
||||
it("keeps estimate read tools aligned to controller/manager/admin visibility and cost requirements", () => {
|
||||
const controllerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER);
|
||||
const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER);
|
||||
const managerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.MANAGER);
|
||||
const managerWithoutCosts = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.USER);
|
||||
|
||||
expect(controllerNames).toContain("search_estimates");
|
||||
expect(controllerNames).toContain("get_estimate_detail");
|
||||
expect(controllerNames).toContain("list_estimate_versions");
|
||||
expect(controllerNames).toContain("get_estimate_version_snapshot");
|
||||
expect(controllerNames).toContain("get_estimate_weekly_phasing");
|
||||
expect(controllerNames).toContain("get_estimate_commercial_terms");
|
||||
expect(controllerWithoutCosts).toContain("search_estimates");
|
||||
expect(controllerWithoutCosts).not.toContain("get_estimate_detail");
|
||||
expect(controllerWithoutCosts).toContain("list_estimate_versions");
|
||||
expect(controllerWithoutCosts).not.toContain("get_estimate_version_snapshot");
|
||||
expect(controllerWithoutCosts).toContain("get_estimate_weekly_phasing");
|
||||
expect(controllerWithoutCosts).toContain("get_estimate_commercial_terms");
|
||||
expect(managerNames).toContain("search_estimates");
|
||||
expect(managerNames).toContain("get_estimate_detail");
|
||||
expect(managerNames).toContain("list_estimate_versions");
|
||||
expect(managerNames).toContain("get_estimate_version_snapshot");
|
||||
expect(managerNames).toContain("get_estimate_weekly_phasing");
|
||||
expect(managerNames).toContain("get_estimate_commercial_terms");
|
||||
expect(managerWithoutCosts).toContain("search_estimates");
|
||||
expect(managerWithoutCosts).toContain("list_estimate_versions");
|
||||
expect(managerWithoutCosts).not.toContain("get_estimate_version_snapshot");
|
||||
expect(userNames).not.toContain("search_estimates");
|
||||
expect(userNames).not.toContain("get_estimate_detail");
|
||||
expect(userNames).not.toContain("list_estimate_versions");
|
||||
expect(userNames).not.toContain("get_estimate_version_snapshot");
|
||||
expect(userNames).not.toContain("get_estimate_weekly_phasing");
|
||||
expect(userNames).not.toContain("get_estimate_commercial_terms");
|
||||
});
|
||||
|
||||
it("keeps import/dispo parity tools aligned to router roles and permissions", () => {
|
||||
const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER);
|
||||
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const userNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.USER);
|
||||
|
||||
expect(managerNames).toContain("import_csv_data");
|
||||
expect(controllerNames).toContain("export_resources_csv");
|
||||
expect(controllerNames).toContain("export_projects_csv");
|
||||
expect(adminNames).toContain("list_dispo_import_batches");
|
||||
expect(adminNames).toContain("get_dispo_import_batch");
|
||||
expect(adminNames).toContain("stage_dispo_import_batch");
|
||||
expect(adminNames).toContain("validate_dispo_import_batch");
|
||||
expect(adminNames).toContain("cancel_dispo_import_batch");
|
||||
expect(adminNames).toContain("list_dispo_staged_resources");
|
||||
expect(adminNames).toContain("list_dispo_staged_projects");
|
||||
expect(adminNames).toContain("list_dispo_staged_assignments");
|
||||
expect(adminNames).toContain("list_dispo_staged_vacations");
|
||||
expect(adminNames).toContain("list_dispo_staged_unresolved_records");
|
||||
expect(adminNames).toContain("resolve_dispo_staged_record");
|
||||
expect(adminNames).toContain("commit_dispo_import_batch");
|
||||
expect(userNames).not.toContain("import_csv_data");
|
||||
expect(userNames).not.toContain("export_resources_csv");
|
||||
expect(userNames).not.toContain("export_projects_csv");
|
||||
expect(userNames).not.toContain("list_dispo_import_batches");
|
||||
expect(userNames).not.toContain("get_dispo_import_batch");
|
||||
expect(userNames).not.toContain("stage_dispo_import_batch");
|
||||
expect(userNames).not.toContain("validate_dispo_import_batch");
|
||||
expect(userNames).not.toContain("list_dispo_staged_resources");
|
||||
expect(userNames).not.toContain("commit_dispo_import_batch");
|
||||
});
|
||||
|
||||
it("keeps settings and webhook admin tools hidden while preserving protected parity tools", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("get_system_settings");
|
||||
expect(adminNames).toContain("update_system_settings");
|
||||
expect(adminNames).toContain("clear_stored_runtime_secrets");
|
||||
expect(adminNames).toContain("test_ai_connection");
|
||||
expect(adminNames).toContain("test_smtp_connection");
|
||||
expect(adminNames).toContain("test_gemini_connection");
|
||||
expect(adminNames).toContain("list_system_role_configs");
|
||||
expect(adminNames).toContain("update_system_role_config");
|
||||
expect(adminNames).toContain("list_webhooks");
|
||||
expect(adminNames).toContain("get_webhook");
|
||||
expect(adminNames).toContain("create_webhook");
|
||||
expect(adminNames).toContain("update_webhook");
|
||||
expect(adminNames).toContain("delete_webhook");
|
||||
expect(adminNames).toContain("test_webhook");
|
||||
expect(adminNames).toContain("get_ai_configured");
|
||||
|
||||
expect(managerNames).not.toContain("get_system_settings");
|
||||
expect(managerNames).not.toContain("update_system_settings");
|
||||
expect(managerNames).not.toContain("clear_stored_runtime_secrets");
|
||||
expect(managerNames).not.toContain("test_ai_connection");
|
||||
expect(managerNames).not.toContain("get_ai_configured");
|
||||
expect(managerNames).not.toContain("list_system_role_configs");
|
||||
expect(managerNames).not.toContain("update_system_role_config");
|
||||
expect(managerNames).not.toContain("list_webhooks");
|
||||
expect(managerNames).not.toContain("create_webhook");
|
||||
|
||||
expect(userNames).not.toContain("get_system_settings");
|
||||
expect(userNames).not.toContain("update_system_settings");
|
||||
expect(userNames).not.toContain("clear_stored_runtime_secrets");
|
||||
expect(userNames).not.toContain("test_ai_connection");
|
||||
expect(userNames).not.toContain("get_ai_configured");
|
||||
expect(userNames).not.toContain("list_system_role_configs");
|
||||
expect(userNames).not.toContain("update_system_role_config");
|
||||
expect(userNames).not.toContain("list_webhooks");
|
||||
expect(userNames).not.toContain("create_webhook");
|
||||
});
|
||||
|
||||
it("keeps client deletion admin-only while still allowing manager client maintenance", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("create_client");
|
||||
expect(adminNames).toContain("update_client");
|
||||
expect(adminNames).toContain("delete_client");
|
||||
expect(managerNames).toContain("create_client");
|
||||
expect(managerNames).toContain("update_client");
|
||||
expect(managerNames).not.toContain("delete_client");
|
||||
expect(userNames).not.toContain("create_client");
|
||||
expect(userNames).not.toContain("update_client");
|
||||
expect(userNames).not.toContain("delete_client");
|
||||
});
|
||||
|
||||
it("keeps holiday calendar catalog tools admin-only while leaving preview available", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("list_holiday_calendars");
|
||||
expect(adminNames).toContain("get_holiday_calendar");
|
||||
expect(adminNames).toContain("preview_resolved_holiday_calendar");
|
||||
expect(adminNames).toContain("create_holiday_calendar");
|
||||
expect(managerNames).not.toContain("list_holiday_calendars");
|
||||
expect(managerNames).not.toContain("get_holiday_calendar");
|
||||
expect(managerNames).toContain("preview_resolved_holiday_calendar");
|
||||
expect(userNames).not.toContain("list_holiday_calendars");
|
||||
expect(userNames).not.toContain("get_holiday_calendar");
|
||||
expect(userNames).toContain("preview_resolved_holiday_calendar");
|
||||
expect(managerNames).not.toContain("create_holiday_calendar");
|
||||
expect(managerNames).not.toContain("update_holiday_calendar");
|
||||
expect(managerNames).not.toContain("delete_holiday_calendar");
|
||||
expect(managerNames).not.toContain("create_holiday_calendar_entry");
|
||||
expect(managerNames).not.toContain("update_holiday_calendar_entry");
|
||||
expect(managerNames).not.toContain("delete_holiday_calendar_entry");
|
||||
});
|
||||
|
||||
it("keeps country and metro-city mutation tools admin-only while leaving read tools available", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], SystemRole.USER);
|
||||
const userWithManagedResources = getToolNames([PermissionKey.MANAGE_RESOURCES], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("list_countries");
|
||||
expect(adminNames).toContain("create_country");
|
||||
expect(adminNames).toContain("update_country");
|
||||
expect(adminNames).toContain("create_metro_city");
|
||||
expect(adminNames).toContain("update_metro_city");
|
||||
expect(adminNames).toContain("delete_metro_city");
|
||||
expect(managerNames).toContain("list_countries");
|
||||
expect(managerNames).not.toContain("create_country");
|
||||
expect(managerNames).not.toContain("update_country");
|
||||
expect(managerNames).not.toContain("create_metro_city");
|
||||
expect(managerNames).not.toContain("update_metro_city");
|
||||
expect(managerNames).not.toContain("delete_metro_city");
|
||||
expect(userNames).not.toContain("search_resources");
|
||||
expect(userNames).not.toContain("get_country");
|
||||
expect(userNames).not.toContain("list_org_units");
|
||||
expect(userWithResourceOverview).toContain("search_resources");
|
||||
expect(userWithResourceOverview).toContain("get_country");
|
||||
expect(userWithResourceOverview).toContain("list_org_units");
|
||||
expect(userWithManagedResources).toContain("search_resources");
|
||||
expect(userWithManagedResources).toContain("get_country");
|
||||
expect(userWithManagedResources).toContain("list_org_units");
|
||||
});
|
||||
|
||||
it("attaches explicit access metadata to legacy monolithic tools with restricted visibility", () => {
|
||||
const toolAccess = new Map(
|
||||
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.access]),
|
||||
);
|
||||
|
||||
expect(toolAccess.get("run_report")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("simulate_scenario")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("detect_anomalies")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("get_insights_summary")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("lookup_rate")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("list_rate_cards")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresCostView: true,
|
||||
});
|
||||
expect(toolAccess.get("resolve_rate")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresCostView: true,
|
||||
});
|
||||
expect(toolAccess.get("import_csv_data")).toEqual({
|
||||
requiredPermissions: [PermissionKey.IMPORT_DATA],
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
});
|
||||
});
|
||||
|
||||
it("aligns assistant tool visibility with router role and permission rules", () => {
|
||||
const managerWithRolePermission = getToolNames(
|
||||
[PermissionKey.MANAGE_ROLES],
|
||||
SystemRole.MANAGER,
|
||||
);
|
||||
const managerWithoutRolePermission = getToolNames([], SystemRole.MANAGER);
|
||||
|
||||
expect(managerWithRolePermission).toContain("create_role");
|
||||
expect(managerWithRolePermission).toContain("update_role");
|
||||
expect(managerWithRolePermission).toContain("delete_role");
|
||||
expect(managerWithRolePermission).toContain("create_client");
|
||||
expect(managerWithRolePermission).toContain("update_client");
|
||||
expect(managerWithRolePermission).not.toContain("create_org_unit");
|
||||
expect(managerWithRolePermission).not.toContain("update_org_unit");
|
||||
|
||||
expect(managerWithoutRolePermission).not.toContain("create_role");
|
||||
expect(managerWithoutRolePermission).not.toContain("update_role");
|
||||
expect(managerWithoutRolePermission).not.toContain("delete_role");
|
||||
expect(managerWithoutRolePermission).toContain("create_client");
|
||||
expect(managerWithoutRolePermission).toContain("update_client");
|
||||
|
||||
const adminWithRolePermission = getToolNames(
|
||||
[PermissionKey.MANAGE_ROLES],
|
||||
SystemRole.ADMIN,
|
||||
);
|
||||
expect(adminWithRolePermission).toContain("create_org_unit");
|
||||
expect(adminWithRolePermission).toContain("update_org_unit");
|
||||
|
||||
const standardUserTools = getToolNames([], SystemRole.USER);
|
||||
expect(standardUserTools).toContain("get_vacation_balance");
|
||||
expect(standardUserTools).toContain("create_vacation");
|
||||
expect(standardUserTools).toContain("cancel_vacation");
|
||||
expect(standardUserTools).not.toContain("approve_vacation");
|
||||
expect(standardUserTools).not.toContain("reject_vacation");
|
||||
expect(standardUserTools).not.toContain("set_entitlement");
|
||||
|
||||
const managerVacationTools = getToolNames([], SystemRole.MANAGER);
|
||||
expect(managerVacationTools).toContain("approve_vacation");
|
||||
expect(managerVacationTools).toContain("reject_vacation");
|
||||
expect(managerVacationTools).toContain("set_entitlement");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,129 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
return {
|
||||
...actual,
|
||||
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
||||
getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
|
||||
listAssignmentBookings: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
userRole: SystemRole = SystemRole.CONTROLLER,
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole,
|
||||
permissions: new Set(),
|
||||
session: {
|
||||
user: { email: "assistant@example.com", name: "Assistant User", image: null },
|
||||
expires: "2026-03-29T00:00:00.000Z",
|
||||
},
|
||||
dbUser: {
|
||||
id: "user_1",
|
||||
systemRole: userRole,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
roleDefaults: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant audit tools", () => {
|
||||
it("lists audit entries through the real audit router path", async () => {
|
||||
const ctx = createToolContext({
|
||||
auditLog: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "audit_1",
|
||||
entityType: "Project",
|
||||
entityId: "project_1",
|
||||
entityName: "Gelddruckmaschine",
|
||||
action: "UPDATE",
|
||||
userId: "user_1",
|
||||
source: "ui",
|
||||
summary: "Updated project dates",
|
||||
createdAt: new Date("2026-03-28T10:00:00.000Z"),
|
||||
user: {
|
||||
id: "user_1",
|
||||
name: "Larissa",
|
||||
email: "larissa@example.com",
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"list_audit_log_entries",
|
||||
JSON.stringify({
|
||||
entityType: "Project",
|
||||
search: "Gelddruckmaschine",
|
||||
limit: 10,
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
filters: {
|
||||
entityType: "Project",
|
||||
entityId: null,
|
||||
userId: null,
|
||||
action: null,
|
||||
source: null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
search: "Gelddruckmaschine",
|
||||
},
|
||||
itemCount: 1,
|
||||
nextCursor: null,
|
||||
items: [
|
||||
{
|
||||
id: "audit_1",
|
||||
entityType: "Project",
|
||||
entityId: "project_1",
|
||||
entityName: "Gelddruckmaschine",
|
||||
action: "UPDATE",
|
||||
userId: "user_1",
|
||||
source: "ui",
|
||||
summary: "Updated project dates",
|
||||
createdAt: "2026-03-28T10:00:00.000Z",
|
||||
user: {
|
||||
id: "user_1",
|
||||
name: "Larissa",
|
||||
email: "larissa@example.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces controller access for audit tools via the backing router", async () => {
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
auditLog: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
SystemRole.USER,
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"query_change_history",
|
||||
JSON.stringify({ entityType: "Project" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: "You do not have permission to perform this action.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,305 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
permissions: string[] = [],
|
||||
userRole: SystemRole = SystemRole.ADMIN,
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole,
|
||||
permissions: new Set(permissions) as ToolContext["permissions"],
|
||||
session: {
|
||||
user: { email: "assistant@example.com", name: "Assistant User", image: null },
|
||||
expires: "2026-03-29T00:00:00.000Z",
|
||||
},
|
||||
dbUser: {
|
||||
id: "user_1",
|
||||
systemRole: userRole,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
roleDefaults: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant country tools", () => {
|
||||
it("lists countries with schedule rules, active state, and metro cities", async () => {
|
||||
const findMany = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
name: "Deutschland",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
isActive: true,
|
||||
metroCities: [{ id: "city_muc", name: "Munich" }],
|
||||
},
|
||||
{
|
||||
id: "country_es",
|
||||
code: "ES",
|
||||
name: "Spain",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
isActive: true,
|
||||
metroCities: [{ id: "city_mad", name: "Madrid" }],
|
||||
},
|
||||
]);
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findMany,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"list_countries",
|
||||
JSON.stringify({ search: "deu" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
count: number;
|
||||
countries: Array<{
|
||||
code: string;
|
||||
isActive: boolean;
|
||||
metroCities: Array<{ id: string; name: string }>;
|
||||
cities: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith({
|
||||
where: { isActive: true },
|
||||
include: { metroCities: { orderBy: { name: "asc" } } },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
expect(parsed.count).toBe(1);
|
||||
expect(parsed.countries[0]).toMatchObject({
|
||||
code: "DE",
|
||||
isActive: true,
|
||||
cities: ["Munich"],
|
||||
metroCities: [{ id: "city_muc", name: "Munich" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("gets a country by code and exposes schedule details and resource count", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findFirst: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "country_es",
|
||||
code: "ES",
|
||||
name: "Spain",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: {
|
||||
type: "spain",
|
||||
fridayHours: 6.5,
|
||||
summerPeriod: { from: "07-01", to: "09-15" },
|
||||
summerHours: 6.5,
|
||||
regularHours: 9,
|
||||
},
|
||||
isActive: true,
|
||||
metroCities: [{ id: "city_mad", name: "Madrid" }],
|
||||
_count: { resources: 4 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"get_country",
|
||||
JSON.stringify({ identifier: "ES" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
code: string;
|
||||
resourceCount: number | null;
|
||||
scheduleRules: { type: string };
|
||||
metroCities: Array<{ name: string }>;
|
||||
};
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
code: "ES",
|
||||
resourceCount: 4,
|
||||
scheduleRules: { type: "spain" },
|
||||
metroCities: [{ name: "Madrid" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a stable error when a country cannot be resolved", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"get_country",
|
||||
JSON.stringify({ identifier: "Atlantis" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Country not found with the given criteria.",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a country for admin users and returns an invalidation action", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({
|
||||
id: "country_es",
|
||||
code: "ES",
|
||||
name: "Spain",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
isActive: true,
|
||||
metroCities: [],
|
||||
_count: { resources: 0 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"create_country",
|
||||
JSON.stringify({ code: "ES", name: "Spain", dailyWorkingHours: 8 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.action).toEqual({
|
||||
type: "invalidate",
|
||||
scope: ["country", "resource", "holidayCalendar", "vacation"],
|
||||
});
|
||||
expect(result.data).toMatchObject({
|
||||
success: true,
|
||||
country: { code: "ES", name: "Spain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a stable error when creating a country with a duplicate code", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "country_es_existing",
|
||||
code: "ES",
|
||||
name: "Existing Spain",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"create_country",
|
||||
JSON.stringify({ code: "ES", name: "Spain", dailyWorkingHours: 8 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "A country with this code already exists.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a stable error when updating a missing country", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"update_country",
|
||||
JSON.stringify({ id: "country_missing", data: { name: "Atlantis" } }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Country not found with the given criteria.",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses country mutations for non-admin users", async () => {
|
||||
const ctx = createToolContext({ country: {} }, [], SystemRole.MANAGER);
|
||||
|
||||
const result = await executeTool(
|
||||
"create_country",
|
||||
JSON.stringify({ code: "ES", name: "Spain" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Admin role required to perform this action.",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes metro cities only when no resources are assigned", async () => {
|
||||
const ctx = createToolContext({
|
||||
metroCity: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "city_ham",
|
||||
name: "Hamburg",
|
||||
_count: { resources: 0 },
|
||||
}),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"delete_metro_city",
|
||||
JSON.stringify({ id: "city_ham" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.action).toEqual({
|
||||
type: "invalidate",
|
||||
scope: ["country", "resource", "holidayCalendar", "vacation"],
|
||||
});
|
||||
expect(result.data).toMatchObject({
|
||||
success: true,
|
||||
message: "Deleted metro city: Hamburg",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a stable error when updating a missing metro city", async () => {
|
||||
const ctx = createToolContext({
|
||||
metroCity: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"update_metro_city",
|
||||
JSON.stringify({ id: "city_missing", data: { name: "Hamburg-Mitte" } }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Metro city not found with the given criteria.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a stable error when deleting a metro city that is still assigned", async () => {
|
||||
const ctx = createToolContext({
|
||||
metroCity: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "city_ham",
|
||||
name: "Hamburg",
|
||||
_count: { resources: 3 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"delete_metro_city",
|
||||
JSON.stringify({ id: "city_ham" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Metro city cannot be deleted while it is still assigned to resources.",
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,16 @@ export function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
userRole: SystemRole = SystemRole.USER,
|
||||
): ToolContext {
|
||||
const dbWithTransaction = "$transaction" in db
|
||||
? db
|
||||
: {
|
||||
...db,
|
||||
$transaction: async <T>(callback: (tx: ToolContext["db"]) => Promise<T>) =>
|
||||
callback(db as ToolContext["db"]),
|
||||
};
|
||||
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
db: dbWithTransaction as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole,
|
||||
permissions: new Set(),
|
||||
|
||||
@@ -96,4 +96,68 @@ describe("assistant project admin create tools - success", () => {
|
||||
);
|
||||
expect(auditCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies assistant-side default project fields when optional create inputs are omitted", async () => {
|
||||
const projectCreate = vi.fn().mockResolvedValue({
|
||||
id: "project_2",
|
||||
shortCode: "PROJ-DEFAULTS",
|
||||
name: "Project Defaults",
|
||||
status: "DRAFT",
|
||||
});
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
displayName: "Peter Parker",
|
||||
}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: projectCreate,
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: "audit_2" }),
|
||||
},
|
||||
},
|
||||
{
|
||||
userRole: SystemRole.ADMIN,
|
||||
permissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"create_project",
|
||||
JSON.stringify({
|
||||
shortCode: "PROJ-DEFAULTS",
|
||||
name: "Project Defaults",
|
||||
orderType: "CHARGEABLE",
|
||||
budgetCents: 250000,
|
||||
startDate: "2026-07-01",
|
||||
endDate: "2026-07-31",
|
||||
responsiblePerson: "Peter Parker",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
projectId: "project_2",
|
||||
shortCode: "PROJ-DEFAULTS",
|
||||
}),
|
||||
);
|
||||
expect(projectCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
allocationType: "INT",
|
||||
winProbability: 100,
|
||||
status: "DRAFT",
|
||||
staffingReqs: [],
|
||||
dynamicFields: {},
|
||||
responsiblePerson: "Peter Parker",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,4 +94,89 @@ describe("assistant project admin create tools - validation", () => {
|
||||
expect(resourceFindFirst).not.toHaveBeenCalled();
|
||||
expect(projectCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the responsible person resolution error when no active resource matches", async () => {
|
||||
const projectCreate = vi.fn();
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: projectCreate,
|
||||
},
|
||||
},
|
||||
{
|
||||
userRole: SystemRole.ADMIN,
|
||||
permissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"create_project",
|
||||
JSON.stringify({
|
||||
shortCode: "PROJ-RP-MISSING",
|
||||
name: "Missing Responsible Match",
|
||||
orderType: "CHARGEABLE",
|
||||
budgetCents: 150000,
|
||||
startDate: "2026-05-01",
|
||||
endDate: "2026-06-30",
|
||||
responsiblePerson: "Mary Jane",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: 'No active resource found matching "Mary Jane". The responsible person must be an existing resource.',
|
||||
});
|
||||
expect(projectCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a stable assistant error when the referenced client cannot be resolved", async () => {
|
||||
const projectCreate = vi.fn();
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
displayName: "Peter Parker",
|
||||
}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: projectCreate,
|
||||
},
|
||||
client: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
userRole: SystemRole.ADMIN,
|
||||
permissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"create_project",
|
||||
JSON.stringify({
|
||||
shortCode: "PROJ-NO-CLIENT",
|
||||
name: "Missing Client",
|
||||
orderType: "CHARGEABLE",
|
||||
budgetCents: 150000,
|
||||
startDate: "2026-05-01",
|
||||
endDate: "2026-06-30",
|
||||
responsiblePerson: "Peter Parker",
|
||||
clientName: "Missing Client",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: 'Client not found: "Missing Client"',
|
||||
});
|
||||
expect(projectCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,4 +116,32 @@ describe("assistant project admin delete tools", () => {
|
||||
error: "Project not found: project_1",
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns a stable assistant error when the project cannot be resolved before deletion", async () => {
|
||||
const transaction = vi.fn();
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
$transaction: transaction,
|
||||
},
|
||||
{
|
||||
userRole: SystemRole.ADMIN,
|
||||
permissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"delete_project",
|
||||
JSON.stringify({ projectId: "missing-project" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Project not found: missing-project",
|
||||
});
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,4 +108,68 @@ describe("assistant project admin update tools", () => {
|
||||
error: "Project not found with the given criteria.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a stable assistant error when no update fields are provided", async () => {
|
||||
const projectUpdate = vi.fn();
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
project: {
|
||||
findUnique: vi.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(createProject()),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
update: projectUpdate,
|
||||
},
|
||||
},
|
||||
{
|
||||
userRole: SystemRole.ADMIN,
|
||||
permissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"update_project",
|
||||
JSON.stringify({ id: "PROJ-1" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "No fields to update",
|
||||
});
|
||||
expect(projectUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns responsible person resolver errors unchanged during update", async () => {
|
||||
const projectUpdate = vi.fn();
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
project: {
|
||||
findUnique: vi.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(createProject()),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
update: projectUpdate,
|
||||
},
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
{
|
||||
userRole: SystemRole.ADMIN,
|
||||
permissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"update_project",
|
||||
JSON.stringify({ id: "PROJ-1", responsiblePerson: "Mary Jane" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: 'No active resource found matching "Mary Jane". The responsible person must be an existing resource.',
|
||||
});
|
||||
expect(projectUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +118,60 @@ describe("notification procedure support", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rewrites notification recipient foreign-key errors to a not found TRPC error", () => {
|
||||
const error = {
|
||||
code: "P2003",
|
||||
meta: { field_name: "Notification_userId_fkey" },
|
||||
};
|
||||
|
||||
try {
|
||||
rethrowNotificationReferenceError(error);
|
||||
throw new Error("expected notification reference error");
|
||||
} catch (caught) {
|
||||
expect(caught).toBeInstanceOf(TRPCError);
|
||||
expect(caught).toMatchObject<Partial<TRPCError>>({
|
||||
code: "NOT_FOUND",
|
||||
message: "Notification recipient user not found",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rewrites task recipient foreign-key errors to a not found TRPC error", () => {
|
||||
const error = {
|
||||
code: "P2003",
|
||||
meta: { field_name: "Notification_userId_fkey" },
|
||||
};
|
||||
|
||||
try {
|
||||
rethrowNotificationReferenceError(error, "task");
|
||||
throw new Error("expected notification reference error");
|
||||
} catch (caught) {
|
||||
expect(caught).toBeInstanceOf(TRPCError);
|
||||
expect(caught).toMatchObject<Partial<TRPCError>>({
|
||||
code: "NOT_FOUND",
|
||||
message: "Task recipient user not found",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rewrites broadcast recipient foreign-key errors to a not found TRPC error", () => {
|
||||
const error = {
|
||||
code: "P2003",
|
||||
meta: { field_name: "Notification_userId_fkey" },
|
||||
};
|
||||
|
||||
try {
|
||||
rethrowNotificationReferenceError(error, "broadcast");
|
||||
throw new Error("expected notification reference error");
|
||||
} catch (caught) {
|
||||
expect(caught).toBeInstanceOf(TRPCError);
|
||||
expect(caught).toMatchObject<Partial<TRPCError>>({
|
||||
code: "NOT_FOUND",
|
||||
message: "Broadcast recipient user not found",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rethrows unrelated errors unchanged", () => {
|
||||
const error = new Error("boom");
|
||||
|
||||
|
||||
@@ -72,6 +72,21 @@ function createManagerCaller(db: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
function createAdminCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "admin@example.com", name: "Admin", image: null },
|
||||
expires: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "user_admin",
|
||||
systemRole: SystemRole.ADMIN,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sample data ──────────────────────────────────────────────────────────────
|
||||
|
||||
function sampleNotification(overrides: Record<string, unknown> = {}) {
|
||||
@@ -281,6 +296,45 @@ describe("notification.create", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults task-like managed notifications to OPEN when no taskStatus is provided", async () => {
|
||||
const created = sampleNotification({
|
||||
userId: "target_user",
|
||||
category: "TASK",
|
||||
taskStatus: "OPEN",
|
||||
});
|
||||
const db = withUserLookup(
|
||||
{
|
||||
notification: {
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
findUnique: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
},
|
||||
"user_mgr",
|
||||
);
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.create({
|
||||
userId: "target_user",
|
||||
type: "TASK_CREATED",
|
||||
title: "Review proposal",
|
||||
category: "TASK",
|
||||
});
|
||||
|
||||
expect(db.notification.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
category: "TASK",
|
||||
taskStatus: "OPEN",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
id: "notif_1",
|
||||
category: "TASK",
|
||||
taskStatus: "OPEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects creation by a regular user (FORBIDDEN)", async () => {
|
||||
const db = withUserLookup({
|
||||
notification: {
|
||||
@@ -293,6 +347,36 @@ describe("notification.create", () => {
|
||||
caller.create({ userId: "target", type: "INFO", title: "Nope" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("maps missing notification recipients to a not found error", async () => {
|
||||
const db = withUserLookup(
|
||||
{
|
||||
notification: {
|
||||
create: vi.fn().mockRejectedValue({
|
||||
code: "P2003",
|
||||
message: "Foreign key constraint failed",
|
||||
meta: { field_name: "Notification_userId_fkey" },
|
||||
}),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
"user_mgr",
|
||||
);
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
userId: "user_missing",
|
||||
type: "INFO",
|
||||
title: "Test notification",
|
||||
})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Notification recipient user not found",
|
||||
});
|
||||
|
||||
expect(db.notification.findUnique).not.toHaveBeenCalled();
|
||||
expect(emitNotificationCreated).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createBroadcast ────────────────────────────────────────────────────────
|
||||
@@ -590,6 +674,75 @@ describe("notification.createBroadcast", () => {
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps missing broadcast recipients during fan-out to not found errors", async () => {
|
||||
resolveRecipientsMock.mockResolvedValue(["user_a", "user_missing"]);
|
||||
|
||||
const txCreateBroadcast = vi.fn().mockResolvedValue({
|
||||
id: "broadcast_tx_missing_recipient",
|
||||
title: "Ops update",
|
||||
createdAt: new Date("2026-03-30T10:00:00Z"),
|
||||
});
|
||||
const txUpdateBroadcast = vi.fn();
|
||||
const txCreateNotification = vi.fn()
|
||||
.mockResolvedValueOnce({ id: "notif_a", userId: "user_a" })
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("Foreign key constraint failed"), {
|
||||
code: "P2003",
|
||||
meta: { field_name: "Notification_userId_fkey" },
|
||||
}),
|
||||
);
|
||||
const tx = {
|
||||
notificationBroadcast: {
|
||||
create: txCreateBroadcast,
|
||||
update: txUpdateBroadcast,
|
||||
},
|
||||
notification: {
|
||||
create: txCreateNotification,
|
||||
},
|
||||
};
|
||||
const outerCreateBroadcast = vi.fn();
|
||||
const outerUpdateBroadcast = vi.fn();
|
||||
const outerCreateNotification = vi.fn();
|
||||
const db = {
|
||||
$transaction: vi.fn(async (callback: (db: typeof tx) => Promise<unknown>) => callback(tx)),
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
notificationBroadcast: {
|
||||
create: outerCreateBroadcast,
|
||||
update: outerUpdateBroadcast,
|
||||
},
|
||||
notification: {
|
||||
create: outerCreateNotification,
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.createBroadcast({
|
||||
title: "Ops update",
|
||||
body: "Email everyone",
|
||||
channel: "both",
|
||||
targetType: "all",
|
||||
})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Broadcast recipient user not found",
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(txCreateBroadcast).toHaveBeenCalledTimes(1);
|
||||
expect(txCreateNotification).toHaveBeenCalledTimes(2);
|
||||
expect(txUpdateBroadcast).not.toHaveBeenCalled();
|
||||
expect(outerCreateBroadcast).not.toHaveBeenCalled();
|
||||
expect(outerUpdateBroadcast).not.toHaveBeenCalled();
|
||||
expect(outerCreateNotification).not.toHaveBeenCalled();
|
||||
expect(db.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(sendEmailMock).not.toHaveBeenCalled();
|
||||
expect(emitNotificationCreated).not.toHaveBeenCalled();
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits recipient SSE only after an immediate broadcast commits", async () => {
|
||||
resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]);
|
||||
|
||||
@@ -1301,6 +1454,78 @@ describe("notification.updateTaskStatus", () => {
|
||||
});
|
||||
|
||||
describe("notification.assignTask", () => {
|
||||
it("returns NOT_FOUND when assigning a missing task", async () => {
|
||||
const update = vi.fn();
|
||||
const db = {
|
||||
notification: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
update,
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.assignTask({ id: "task_missing", assigneeId: "user_4" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Task not found",
|
||||
});
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps missing task recipients to a not found error without side effects", async () => {
|
||||
const db = {
|
||||
notification: {
|
||||
create: vi.fn().mockRejectedValue({
|
||||
code: "P2003",
|
||||
message: "Foreign key constraint failed",
|
||||
meta: { field_name: "Notification_userId_fkey" },
|
||||
}),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.createTask({
|
||||
userId: "user_missing",
|
||||
title: "Review proposal",
|
||||
channel: "in_app",
|
||||
})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Task recipient user not found",
|
||||
});
|
||||
|
||||
expect(db.notification.findUnique).not.toHaveBeenCalled();
|
||||
expect(emitNotificationCreated).not.toHaveBeenCalled();
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects assigning non-task notifications", async () => {
|
||||
const update = vi.fn();
|
||||
const db = {
|
||||
notification: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "notif_9",
|
||||
category: "REMINDER",
|
||||
assigneeId: null,
|
||||
}),
|
||||
update,
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.assignTask({ id: "notif_9", assigneeId: "user_4" })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Only tasks and approvals can be assigned",
|
||||
});
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reassigns a task and emits the assignment event for the new assignee", async () => {
|
||||
const findUnique = vi.fn().mockResolvedValue({
|
||||
id: "task_9",
|
||||
@@ -1365,6 +1590,103 @@ describe("notification.assignTask", () => {
|
||||
});
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalledWith("user_missing", "task_9");
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND when the task disappears before reassignment is persisted", async () => {
|
||||
const findUnique = vi.fn().mockResolvedValue({
|
||||
id: "task_9",
|
||||
category: "TASK",
|
||||
assigneeId: "user_2",
|
||||
});
|
||||
const update = vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error("Record to update not found"), {
|
||||
code: "P2025",
|
||||
}),
|
||||
);
|
||||
const db = {
|
||||
notification: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.assignTask({ id: "task_9", assigneeId: "user_4" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Task not found",
|
||||
});
|
||||
|
||||
expect(findUnique).toHaveBeenCalledWith({ where: { id: "task_9" } });
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: "task_9" },
|
||||
data: { assigneeId: "user_4" },
|
||||
});
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification.executeTaskAction", () => {
|
||||
it("rejects dismissed tasks before executing their domain action", async () => {
|
||||
const updateAssignment = vi.fn();
|
||||
const db = withUserLookup({
|
||||
notification: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "task_1",
|
||||
userId: "user_1",
|
||||
assigneeId: null,
|
||||
taskAction: "confirm_assignment:assign_1",
|
||||
taskStatus: "DISMISSED",
|
||||
}),
|
||||
update: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findUnique: vi.fn(),
|
||||
update: updateAssignment,
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
|
||||
await expect(caller.executeTaskAction({ id: "task_1" })).rejects.toMatchObject({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "This task has been dismissed",
|
||||
});
|
||||
|
||||
expect(updateAssignment).not.toHaveBeenCalled();
|
||||
expect(db.notification.update).not.toHaveBeenCalled();
|
||||
expect(emitTaskCompleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects task action execution when transactional persistence support is unavailable", async () => {
|
||||
const updateVacation = vi.fn();
|
||||
const db = withUserLookup({
|
||||
notification: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "task_1",
|
||||
userId: "user_1",
|
||||
assigneeId: null,
|
||||
taskAction: "approve_vacation:vac_1",
|
||||
taskStatus: "OPEN",
|
||||
}),
|
||||
update: vi.fn(),
|
||||
},
|
||||
vacation: {
|
||||
findUnique: vi.fn(),
|
||||
update: updateVacation,
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
|
||||
await expect(caller.executeTaskAction({ id: "task_1" })).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Task action execution requires transactional persistence support.",
|
||||
});
|
||||
|
||||
expect(updateVacation).not.toHaveBeenCalled();
|
||||
expect(db.notification.update).not.toHaveBeenCalled();
|
||||
expect(emitTaskCompleted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reminders ──────────────────────────────────────────────────────────────
|
||||
@@ -1467,6 +1789,28 @@ describe("notification.updateReminder", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND when the reminder is missing or belongs to another user", async () => {
|
||||
const update = vi.fn();
|
||||
const db = withUserLookup({
|
||||
notification: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
update,
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.updateReminder({
|
||||
id: "rem_missing",
|
||||
title: "Updated reminder",
|
||||
})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Reminder not found or you do not have permission",
|
||||
});
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification.deleteReminder", () => {
|
||||
@@ -1492,6 +1836,25 @@ describe("notification.deleteReminder", () => {
|
||||
});
|
||||
expect(deleteFn).toHaveBeenCalledWith({ where: { id: "rem_1" } });
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND when the reminder is missing or belongs to another user", async () => {
|
||||
const deleteFn = vi.fn();
|
||||
const db = withUserLookup({
|
||||
notification: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
delete: deleteFn,
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.deleteReminder({ id: "rem_missing" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Reminder not found or you do not have permission",
|
||||
});
|
||||
|
||||
expect(deleteFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification.listReminders", () => {
|
||||
|
||||
@@ -59,6 +59,67 @@ describe("report router", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("exposes extended resource and project basis columns for report completeness", async () => {
|
||||
const caller = createControllerCaller({});
|
||||
|
||||
const resourceColumns = await caller.getAvailableColumns({ entity: "resource" });
|
||||
const projectColumns = await caller.getAvailableColumns({ entity: "project" });
|
||||
|
||||
expect(resourceColumns).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: "enterpriseId", label: "Enterprise ID" }),
|
||||
expect.objectContaining({ key: "valueScore", label: "Value Score" }),
|
||||
expect.objectContaining({ key: "blueprint.name", label: "Blueprint" }),
|
||||
expect.objectContaining({ key: "clientUnit.name", label: "Client Unit" }),
|
||||
]));
|
||||
expect(projectColumns).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: "shoringThreshold", label: "Shoring Threshold (%)" }),
|
||||
expect.objectContaining({ key: "onshoreCountryCode", label: "Onshore Country Code" }),
|
||||
expect.objectContaining({ key: "color", label: "Color" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("lists backend-managed report blueprints for resource_month", async () => {
|
||||
const caller = createControllerCaller({});
|
||||
const blueprints = await caller.listBlueprints({ entity: "resource_month" });
|
||||
|
||||
expect(blueprints).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "resource-month-sah-transparency",
|
||||
entity: "resource_month",
|
||||
templateName: "Monthly SAH transparency",
|
||||
config: expect.objectContaining({
|
||||
entity: "resource_month",
|
||||
sortBy: "displayName",
|
||||
sortDir: "asc",
|
||||
filters: [],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "resource-month-chargeability-audit",
|
||||
entity: "resource_month",
|
||||
templateName: "Monthly chargeability audit",
|
||||
config: expect.objectContaining({
|
||||
entity: "resource_month",
|
||||
sortBy: "monthlyActualChargeabilityPct",
|
||||
sortDir: "desc",
|
||||
filters: [],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "resource-month-location-comparison",
|
||||
entity: "resource_month",
|
||||
templateName: "Monthly holiday comparison by location",
|
||||
config: expect.objectContaining({
|
||||
entity: "resource_month",
|
||||
groupBy: "federalState",
|
||||
sortBy: "monthlyPublicHolidayHoursDeduction",
|
||||
sortDir: "desc",
|
||||
filters: [],
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("exports resource month basis and computed columns in CSV", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -114,6 +175,42 @@ describe("report router", () => {
|
||||
expect(result.rowCount).toBe(1);
|
||||
expect(result.csv).toContain("Name,Country Code,Holiday Dates,Holiday Hours Deduction,Absence Hours Deduction,SAH,Target Hours,Unassigned Hours");
|
||||
expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156");
|
||||
expect(result.columns).toEqual([
|
||||
"id",
|
||||
"displayName",
|
||||
"countryCode",
|
||||
"monthlyPublicHolidayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyTargetHours",
|
||||
"monthlyUnassignedHours",
|
||||
]);
|
||||
expect(result.explainability).toEqual({
|
||||
entity: "resource_month",
|
||||
periodMonth: "2026-04",
|
||||
locationContextColumns: ["countryCode"],
|
||||
holidayMetricColumns: ["monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction"],
|
||||
absenceMetricColumns: ["monthlyAbsenceHoursDeduction"],
|
||||
capacityMetricColumns: ["monthlySahHours", "monthlyTargetHours"],
|
||||
chargeabilityMetricColumns: ["monthlyUnassignedHours"],
|
||||
missingRecommendedColumns: [
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlyPublicHolidayWorkdayCount",
|
||||
"monthlyAbsenceDayEquivalent",
|
||||
"monthlyBaseWorkingDays",
|
||||
"monthlyEffectiveWorkingDays",
|
||||
"monthlyBaseAvailableHours",
|
||||
"monthlyChargeabilityTargetPct",
|
||||
],
|
||||
notes: [
|
||||
"monthlySahHours already reflects region-specific public holidays from country, federal state, and city context when those attributes exist on the resource.",
|
||||
"monthlyAbsence* metrics only deduct workdays that are not already counted as public holidays.",
|
||||
"monthlyBaseAvailableHours shows pre-deduction capacity; compare it with holiday, absence, and SAH columns to explain the final monthly availability.",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps holiday and absence deductions separate in resource_month rows", async () => {
|
||||
@@ -178,6 +275,132 @@ describe("report router", () => {
|
||||
monthlySahHours: 156,
|
||||
},
|
||||
]);
|
||||
expect(result.explainability).toEqual({
|
||||
entity: "resource_month",
|
||||
periodMonth: "2026-04",
|
||||
locationContextColumns: [],
|
||||
holidayMetricColumns: ["monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction"],
|
||||
absenceMetricColumns: ["monthlyAbsenceDayEquivalent", "monthlyAbsenceHoursDeduction"],
|
||||
capacityMetricColumns: ["monthlySahHours"],
|
||||
chargeabilityMetricColumns: [],
|
||||
missingRecommendedColumns: [
|
||||
"countryCode",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlyPublicHolidayWorkdayCount",
|
||||
"monthlyBaseWorkingDays",
|
||||
"monthlyEffectiveWorkingDays",
|
||||
"monthlyBaseAvailableHours",
|
||||
"monthlyChargeabilityTargetPct",
|
||||
"monthlyTargetHours",
|
||||
],
|
||||
notes: [
|
||||
"monthlySahHours already reflects region-specific public holidays from country, federal state, and city context when those attributes exist on the resource.",
|
||||
"monthlyAbsence* metrics only deduct workdays that are not already counted as public holidays.",
|
||||
"monthlyBaseAvailableHours shows pre-deduction capacity; compare it with holiday, absence, and SAH columns to explain the final monthly availability.",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("flattens extended assignment resource and project context columns", async () => {
|
||||
const db = {
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "asg_1",
|
||||
hoursPerDay: 6,
|
||||
resource: {
|
||||
displayName: "Alice",
|
||||
resourceType: "EMPLOYEE",
|
||||
chargeabilityTarget: 85,
|
||||
orgUnit: { name: "Delivery" },
|
||||
managementLevelGroup: { name: "Senior IC" },
|
||||
managementLevel: { name: "Senior Artist" },
|
||||
},
|
||||
project: {
|
||||
name: "Gelddruckmaschine",
|
||||
orderType: "TIME_AND_MATERIAL",
|
||||
allocationType: "PROJECT",
|
||||
blueprint: { name: "Consulting Blueprint" },
|
||||
utilizationCategory: { name: "Billable" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getReportData({
|
||||
entity: "assignment",
|
||||
columns: [
|
||||
"resource.displayName",
|
||||
"resource.resourceType",
|
||||
"resource.chargeabilityTarget",
|
||||
"resource.orgUnit.name",
|
||||
"resource.managementLevelGroup.name",
|
||||
"resource.managementLevel.name",
|
||||
"project.name",
|
||||
"project.orderType",
|
||||
"project.allocationType",
|
||||
"project.blueprint.name",
|
||||
"project.utilizationCategory.name",
|
||||
"hoursPerDay",
|
||||
],
|
||||
filters: [],
|
||||
sortBy: "hoursPerDay",
|
||||
sortDir: "desc",
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(db.assignment.findMany).toHaveBeenCalledWith({
|
||||
select: {
|
||||
id: true,
|
||||
hoursPerDay: true,
|
||||
resource: {
|
||||
select: {
|
||||
displayName: true,
|
||||
resourceType: true,
|
||||
chargeabilityTarget: true,
|
||||
orgUnit: { select: { name: true } },
|
||||
managementLevelGroup: { select: { name: true } },
|
||||
managementLevel: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
project: {
|
||||
select: {
|
||||
name: true,
|
||||
orderType: true,
|
||||
allocationType: true,
|
||||
blueprint: { select: { name: true } },
|
||||
utilizationCategory: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {},
|
||||
orderBy: [{ hoursPerDay: "desc" }],
|
||||
take: 10,
|
||||
skip: 0,
|
||||
});
|
||||
expect(result.rows).toEqual([
|
||||
{
|
||||
id: "asg_1",
|
||||
"resource.displayName": "Alice",
|
||||
"resource.resourceType": "EMPLOYEE",
|
||||
"resource.chargeabilityTarget": 85,
|
||||
"resource.orgUnit.name": "Delivery",
|
||||
"resource.managementLevelGroup.name": "Senior IC",
|
||||
"resource.managementLevel.name": "Senior Artist",
|
||||
"project.name": "Gelddruckmaschine",
|
||||
"project.orderType": "TIME_AND_MATERIAL",
|
||||
"project.allocationType": "PROJECT",
|
||||
"project.blueprint.name": "Consulting Blueprint",
|
||||
"project.utilizationCategory.name": "Billable",
|
||||
hoursPerDay: 6,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects invalid resource_month period months instead of silently normalizing them", async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildResourceMonthTemplateCompleteness } from "../router/report-blueprints-support.js";
|
||||
import {
|
||||
DeleteReportTemplateInputSchema,
|
||||
deleteReportTemplate,
|
||||
@@ -21,6 +22,36 @@ function createContext(reportTemplate: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
describe("report template procedure support", () => {
|
||||
it("reuses the shared resource month completeness basis", () => {
|
||||
expect(buildResourceMonthTemplateCompleteness([
|
||||
"monthKey",
|
||||
"displayName",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlyPublicHolidayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceDayEquivalent",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyTargetHours",
|
||||
"monthlyActualBookedHours",
|
||||
"monthlyUnassignedHours",
|
||||
])).toMatchObject({
|
||||
scope: "resource_month",
|
||||
isAuditReady: true,
|
||||
isRecommendedComplete: false,
|
||||
minimumAuditColumnCount: 13,
|
||||
selectedMinimumAuditColumnCount: 13,
|
||||
missingMinimumAuditColumns: [],
|
||||
missingRecommendedColumns: expect.arrayContaining([
|
||||
"eid",
|
||||
"chapter",
|
||||
"monthlyExpectedBookedHours",
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("lists shared and owned templates with parsed config and ownership flags", async () => {
|
||||
const updatedAt = new Date("2026-03-31T10:00:00.000Z");
|
||||
const ctx = createContext({
|
||||
|
||||
@@ -24,7 +24,10 @@ vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { carveTimelineAllocationRange } from "../router/timeline-allocation-fragment-support.js";
|
||||
import {
|
||||
carveTimelineAllocationRange,
|
||||
extractTimelineAllocationFragment,
|
||||
} from "../router/timeline-allocation-fragment-support.js";
|
||||
|
||||
function createResolvedAssignment() {
|
||||
return {
|
||||
@@ -172,4 +175,83 @@ describe("timeline allocation fragment support", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts a middle segment into the original assignment and creates siblings", async () => {
|
||||
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
|
||||
updateAssignmentMock.mockResolvedValue({ id: "assignment_1" });
|
||||
createAssignmentMock
|
||||
.mockResolvedValueOnce({ id: "assignment_left" })
|
||||
.mockResolvedValueOnce({ id: "assignment_right" });
|
||||
|
||||
const db = {
|
||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||
};
|
||||
|
||||
const result = await extractTimelineAllocationFragment({
|
||||
db: db as never,
|
||||
allocationId: "assignment_1",
|
||||
startDate: new Date("2026-04-09T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "extracted",
|
||||
allocationGroupId: expect.any(String),
|
||||
extractedAllocationId: "assignment_1",
|
||||
updatedAllocationIds: ["assignment_1"],
|
||||
createdAllocationIds: ["assignment_left", "assignment_right"],
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
expect(updateAssignmentMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
createAssignmentMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
expect(updateAssignmentMock).toHaveBeenCalledWith(
|
||||
db,
|
||||
"assignment_1",
|
||||
expect.objectContaining({
|
||||
startDate: new Date("2026-04-09T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
expect(createAssignmentMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
db,
|
||||
expect.objectContaining({
|
||||
startDate: new Date("2026-04-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-08T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
expect(createAssignmentMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
db,
|
||||
expect.objectContaining({
|
||||
startDate: new Date("2026-04-11T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-17T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns unchanged when extracting the full assignment range", async () => {
|
||||
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
|
||||
|
||||
const result = await extractTimelineAllocationFragment({
|
||||
db: { $transaction: vi.fn() } as never,
|
||||
allocationId: "assignment_1",
|
||||
startDate: new Date("2026-04-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-17T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "unchanged",
|
||||
allocationGroupId: expect.any(String),
|
||||
extractedAllocationId: "assignment_1",
|
||||
updatedAllocationIds: [],
|
||||
createdAllocationIds: [],
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
expect(updateAssignmentMock).not.toHaveBeenCalled();
|
||||
expect(createAssignmentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../sse/event-bus.js", () => ({
|
||||
emitAllocationCreated: vi.fn(),
|
||||
emitAllocationDeleted: vi.fn(),
|
||||
emitAllocationUpdated: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -13,11 +15,20 @@ vi.mock("../router/timeline-allocation-inline-support.js", () => ({
|
||||
applyTimelineInlineAllocationUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../router/timeline-allocation-fragment-support.js", () => ({
|
||||
carveTimelineAllocationRange: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../router/timeline-allocation-procedure-support.js", () => ({
|
||||
shiftTimelineAllocations: vi.fn(),
|
||||
}));
|
||||
|
||||
import { emitAllocationUpdated } from "../sse/event-bus.js";
|
||||
import {
|
||||
emitAllocationCreated,
|
||||
emitAllocationDeleted,
|
||||
emitAllocationUpdated,
|
||||
} from "../sse/event-bus.js";
|
||||
import { carveTimelineAllocationRange } from "../router/timeline-allocation-fragment-support.js";
|
||||
import {
|
||||
createTimelineBatchQuickAssignments,
|
||||
createTimelineQuickAssignment,
|
||||
@@ -26,12 +37,16 @@ import { applyTimelineInlineAllocationUpdate } from "../router/timeline-allocati
|
||||
import { shiftTimelineAllocations } from "../router/timeline-allocation-procedure-support.js";
|
||||
import {
|
||||
applyTimelineAllocationBatchShiftMutation,
|
||||
carveTimelineAllocationRangeMutation,
|
||||
createTimelineBatchQuickAssignMutation,
|
||||
createTimelineQuickAssignMutation,
|
||||
updateTimelineAllocationInlineMutation,
|
||||
} from "../router/timeline-allocation-router-support.js";
|
||||
|
||||
const emitAllocationCreatedMock = vi.mocked(emitAllocationCreated);
|
||||
const emitAllocationDeletedMock = vi.mocked(emitAllocationDeleted);
|
||||
const emitAllocationUpdatedMock = vi.mocked(emitAllocationUpdated);
|
||||
const carveTimelineAllocationRangeMock = vi.mocked(carveTimelineAllocationRange);
|
||||
const createTimelineBatchQuickAssignmentsMock = vi.mocked(createTimelineBatchQuickAssignments);
|
||||
const createTimelineQuickAssignmentMock = vi.mocked(createTimelineQuickAssignment);
|
||||
const applyTimelineInlineAllocationUpdateMock = vi.mocked(applyTimelineInlineAllocationUpdate);
|
||||
@@ -183,4 +198,59 @@ describe("timeline allocation router support", () => {
|
||||
mode: "preserve-duration",
|
||||
});
|
||||
});
|
||||
|
||||
it("carves an allocation range and emits update/create/delete events for every affected fragment", async () => {
|
||||
const db = {} as never;
|
||||
const startDate = new Date("2026-04-09T00:00:00.000Z");
|
||||
const endDate = new Date("2026-04-10T00:00:00.000Z");
|
||||
|
||||
carveTimelineAllocationRangeMock.mockResolvedValueOnce({
|
||||
action: "split",
|
||||
allocationGroupId: "group_1",
|
||||
updatedAllocationIds: ["allocation_left"],
|
||||
createdAllocationIds: ["allocation_right"],
|
||||
deletedAllocationIds: ["allocation_removed"],
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
carveTimelineAllocationRangeMutation({
|
||||
db,
|
||||
allocationId: "allocation_1",
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "split",
|
||||
allocationGroupId: "group_1",
|
||||
updatedAllocationIds: ["allocation_left"],
|
||||
createdAllocationIds: ["allocation_right"],
|
||||
deletedAllocationIds: ["allocation_removed"],
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
|
||||
expect(carveTimelineAllocationRangeMock).toHaveBeenCalledWith({
|
||||
db,
|
||||
allocationId: "allocation_1",
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
expect(emitAllocationUpdatedMock).toHaveBeenCalledWith({
|
||||
id: "allocation_left",
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
expect(emitAllocationCreatedMock).toHaveBeenCalledWith({
|
||||
id: "allocation_right",
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
expect(emitAllocationDeletedMock).toHaveBeenCalledWith(
|
||||
"allocation_removed",
|
||||
"project_1",
|
||||
"resource_1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,6 +222,124 @@ describe("timeline allocation entry resolution", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts a visible subrange into its own assignment fragment", async () => {
|
||||
const existingAssignment = {
|
||||
id: "assignment_1",
|
||||
demandRequirementId: null,
|
||||
resourceId: "resource_1",
|
||||
projectId: "project_1",
|
||||
startDate: new Date("2026-03-16"),
|
||||
endDate: new Date("2026-03-27"),
|
||||
hoursPerDay: 8,
|
||||
percentage: 100,
|
||||
role: "Compositor",
|
||||
roleId: "role_comp",
|
||||
dailyCostCents: 40000,
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-03-13"),
|
||||
updatedAt: new Date("2026-03-13"),
|
||||
resource: {
|
||||
id: "resource_1",
|
||||
displayName: "Alice",
|
||||
eid: "E-001",
|
||||
lcrCents: 5000,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
},
|
||||
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
|
||||
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
|
||||
demandRequirement: null,
|
||||
};
|
||||
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "resource_1",
|
||||
lcrCents: 5000,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
demandRequirement: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
assignment: {
|
||||
findUnique: vi.fn().mockResolvedValue(existingAssignment),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn().mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||
...existingAssignment,
|
||||
...data,
|
||||
metadata: data.metadata ?? existingAssignment.metadata,
|
||||
})),
|
||||
create: vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
...existingAssignment,
|
||||
id: "assignment_left",
|
||||
startDate: new Date("2026-03-16"),
|
||||
endDate: new Date("2026-03-20"),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
...existingAssignment,
|
||||
id: "assignment_right",
|
||||
startDate: new Date("2026-03-26"),
|
||||
endDate: new Date("2026-03-27"),
|
||||
}),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.extractAllocationFragment({
|
||||
allocationId: "assignment_1",
|
||||
startDate: new Date("2026-03-23"),
|
||||
endDate: new Date("2026-03-25"),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "extracted",
|
||||
allocationGroupId: expect.any(String),
|
||||
extractedAllocationId: "assignment_1",
|
||||
updatedAllocationIds: ["assignment_1"],
|
||||
createdAllocationIds: ["assignment_left", "assignment_right"],
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
expect(db.assignment.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "assignment_1" },
|
||||
data: expect.objectContaining({
|
||||
startDate: new Date("2026-03-23"),
|
||||
endDate: new Date("2026-03-25"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(db.assignment.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("falls back to default rules when calculationRule and vacation tables are missing", async () => {
|
||||
const existingAssignment = {
|
||||
id: "assignment_legacy_1",
|
||||
|
||||
@@ -142,7 +142,7 @@ describe("timeline router detail views", () => {
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
},
|
||||
]);
|
||||
@@ -177,6 +177,10 @@ describe("timeline router detail views", () => {
|
||||
resourceId: "res_self",
|
||||
note: "Heilige Drei Könige",
|
||||
scope: "STATE",
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Muenchen",
|
||||
}),
|
||||
]);
|
||||
expect(demandFindMany).not.toHaveBeenCalled();
|
||||
@@ -365,7 +369,7 @@ describe("timeline router detail views", () => {
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
},
|
||||
{
|
||||
@@ -373,7 +377,7 @@ describe("timeline router detail views", () => {
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: "city_hamburg",
|
||||
country: { code: "DE" },
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
]),
|
||||
@@ -409,6 +413,10 @@ describe("timeline router detail views", () => {
|
||||
startDate: "2026-01-06",
|
||||
note: "Heilige Drei Könige",
|
||||
scope: "STATE",
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Muenchen",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user