diff --git a/providers/gusto/connector.go b/providers/gusto/connector.go index b14fa047b..7e61ef767 100644 --- a/providers/gusto/connector.go +++ b/providers/gusto/connector.go @@ -7,9 +7,11 @@ package gusto import ( "github.com/amp-labs/connectors/common" "github.com/amp-labs/connectors/internal/components" + "github.com/amp-labs/connectors/internal/components/deleter" "github.com/amp-labs/connectors/internal/components/operations" "github.com/amp-labs/connectors/internal/components/reader" "github.com/amp-labs/connectors/internal/components/schema" + "github.com/amp-labs/connectors/internal/components/writer" "github.com/amp-labs/connectors/providers" "github.com/amp-labs/connectors/providers/gusto/metadata" ) @@ -25,6 +27,8 @@ type Connector struct { components.SchemaProvider components.Reader + components.Writer + components.Deleter companyID string } @@ -62,6 +66,28 @@ func constructor(params common.ConnectorParams) func(*components.Connector) (*Co }, ) + connector.Writer = writer.NewHTTPWriter( + connector.HTTPClient().Client, + components.NewEmptyEndpointRegistry(), + connector.ProviderContext.Module(), + operations.WriteHandlers{ + BuildRequest: connector.buildWriteRequest, + ParseResponse: connector.parseWriteResponse, + ErrorHandler: common.InterpretError, + }, + ) + + connector.Deleter = deleter.NewHTTPDeleter( + connector.HTTPClient().Client, + components.NewEmptyEndpointRegistry(), + connector.ProviderContext.Module(), + operations.DeleteHandlers{ + BuildRequest: connector.buildDeleteRequest, + ParseResponse: connector.parseDeleteResponse, + ErrorHandler: common.InterpretError, + }, + ) + return connector, nil } } diff --git a/providers/gusto/delete.go b/providers/gusto/delete.go new file mode 100644 index 000000000..7de144130 --- /dev/null +++ b/providers/gusto/delete.go @@ -0,0 +1,99 @@ +package gusto + +import ( + "context" + "fmt" + "net/http" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/common/urlbuilder" + "github.com/amp-labs/connectors/internal/datautils" +) + +// Gusto delete API conventions (App Integrations track): +// - Most deletes are TOP-LEVEL by uuid: DELETE /v1/{object}/{uuid} +// - earning_types is COMPANY-SCOPED: DELETE /v1/companies/{cid}/earning_types/{uuid} +// +// Workflow-style deletes (terminations, rehire) follow a different shape and +// are out of scope here — same reasoning as their POST/PUT counterparts in +// write.go (proxy-only). +// +// API references (slug = delete-v1-{path-with-dashes}): +// https://docs.gusto.com/app-integrations/reference/ +// - delete-v1-employee +// - delete-v1-jobs-job_id +// - delete-v1-compensations-compensation_id +// - delete-v1-home_addresses-home_address_uuid +// - delete-v1-work_addresses-work_address_uuid +// - delete-v1-employee_benefits-employee_benefit_id +// - delete-v1-company_benefits-company_benefit_id +// - delete-department +// - delete-v1-companies-company_id-earning_types-earning_type_uuid + +// supportedDeleteObjects enumerates objects Gusto exposes a DELETE endpoint +// for. Many Gusto resources have no DELETE (companies, contractors, +// locations, payrolls, pay_schedules, garnishments, admins, +// contractor_payments, custom_fields). For those we reject with +// ErrOperationNotSupportedForObject. +// +//nolint:gochecknoglobals +var supportedDeleteObjects = datautils.NewStringSet( + objectEmployees, + objectJobs, + objectCompensations, + objectHomeAddresses, + objectWorkAddresses, + objectEmployeeBenefits, + objectCompanyBenefits, + objectDepartments, + objectEarningTypes, +) + +func (c *Connector) buildDeleteRequest(ctx context.Context, params common.DeleteParams) (*http.Request, error) { + if !supportedDeleteObjects.Has(params.ObjectName) { + return nil, common.ErrOperationNotSupportedForObject + } + + url, err := c.buildDeleteURL(params) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url.String(), nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + + return req, nil +} + +// buildDeleteURL routes earning_types through the company-scoped path and all +// other supported objects through the flat top-level path. +func (c *Connector) buildDeleteURL(params common.DeleteParams) (*urlbuilder.URL, error) { + baseURL := c.ProviderInfo().BaseURL + + if companyScopedUpdate.Has(params.ObjectName) { + if c.companyID == "" { + return nil, ErrMissingCompanyID + } + + return urlbuilder.New(baseURL, "v1", "companies", c.companyID, params.ObjectName, params.RecordId) + } + + return urlbuilder.New(baseURL, "v1", params.ObjectName, params.RecordId) +} + +func (c *Connector) parseDeleteResponse( + _ context.Context, + _ common.DeleteParams, + _ *http.Request, + response *common.JSONHTTPResponse, +) (*common.DeleteResult, error) { + if response.Code != http.StatusOK && response.Code != http.StatusNoContent && response.Code != http.StatusAccepted { + return nil, fmt.Errorf("%w: failed to delete record: %d", common.ErrRequestFailed, response.Code) + } + + return &common.DeleteResult{Success: true}, nil +} diff --git a/providers/gusto/delete_test.go b/providers/gusto/delete_test.go new file mode 100644 index 000000000..226ffedba --- /dev/null +++ b/providers/gusto/delete_test.go @@ -0,0 +1,113 @@ +package gusto + +import ( + "net/http" + "testing" + + "github.com/amp-labs/connectors" + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/test/utils/mockutils/mockcond" + "github.com/amp-labs/connectors/test/utils/mockutils/mockserver" + "github.com/amp-labs/connectors/test/utils/testroutines" +) + +func TestDelete(t *testing.T) { //nolint:funlen + t.Parallel() + + tests := []testroutines.Delete{ + { + Name: "Delete object must be included", + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrMissingObjects}, + }, + { + Name: "Delete record ID must be included", + Input: common.DeleteParams{ObjectName: "jobs"}, + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrMissingRecordID}, + }, + { + Name: "Unsupported object returns ErrOperationNotSupportedForObject", + // locations has no DELETE per Gusto's docs; the connector rejects. + Input: common.DeleteParams{ObjectName: "locations", RecordId: "loc_001"}, + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrOperationNotSupportedForObject}, + }, + { + Name: "Top-level DELETE on jobs hits /v1/jobs/{uuid}", + Input: common.DeleteParams{ObjectName: "jobs", RecordId: "job_001"}, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodDELETE(), + mockcond.Path("/v1/jobs/job_001"), + }, + Then: mockserver.Response(http.StatusNoContent, nil), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected"}`), + }.Server(), + Expected: &common.DeleteResult{Success: true}, + }, + { + Name: "Top-level DELETE on home_addresses hits /v1/home_addresses/{uuid}", + Input: common.DeleteParams{ObjectName: "home_addresses", RecordId: "addr_001"}, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodDELETE(), + mockcond.Path("/v1/home_addresses/addr_001"), + }, + Then: mockserver.Response(http.StatusNoContent, nil), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected"}`), + }.Server(), + Expected: &common.DeleteResult{Success: true}, + }, + { + Name: "Company-scoped DELETE on earning_types hits /v1/companies/{cid}/earning_types/{uuid}", + // earning_types delete is nested under company per Gusto's docs + // (delete-v1-companies-company_id-earning_types-earning_type_uuid). + Input: common.DeleteParams{ObjectName: "earning_types", RecordId: "et_001"}, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodDELETE(), + mockcond.Path("/v1/companies/" + testCompanyID + "/earning_types/et_001"), + }, + Then: mockserver.Response(http.StatusNoContent, nil), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected"}`), + }.Server(), + Expected: &common.DeleteResult{Success: true}, + }, + { + Name: "DELETE earning_types without companyID returns ErrMissingCompanyID", + Input: common.DeleteParams{ObjectName: "earning_types", RecordId: "et_001"}, + Server: mockserver.Dummy(), + ExpectedErrs: []error{ErrMissingCompanyID}, + }, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + + tt.Run(t, func() (connectors.DeleteConnector, error) { + if tt.Name == "DELETE earning_types without companyID returns ErrMissingCompanyID" { + return constructTestWriteConnector(tt.Server.URL, "") + } + + return constructTestWriteConnector(tt.Server.URL, testCompanyID) + }) + }) + } +} diff --git a/providers/gusto/test/write/compensation-create.json b/providers/gusto/test/write/compensation-create.json new file mode 100644 index 000000000..358fe46d6 --- /dev/null +++ b/providers/gusto/test/write/compensation-create.json @@ -0,0 +1,8 @@ +{ + "uuid": "comp_001", + "job_uuid": "job_001", + "rate": "100.00", + "payment_unit": "Hour", + "flsa_status": "Nonexempt", + "version": "v1" +} diff --git a/providers/gusto/test/write/earning-type-update.json b/providers/gusto/test/write/earning-type-update.json new file mode 100644 index 000000000..07c43d4bd --- /dev/null +++ b/providers/gusto/test/write/earning-type-update.json @@ -0,0 +1,6 @@ +{ + "uuid": "et_001", + "name": "Bonus Updated", + "company_uuid": "test-company-uuid", + "version": "v2" +} diff --git a/providers/gusto/test/write/employee-create.json b/providers/gusto/test/write/employee-create.json new file mode 100644 index 000000000..ed692a930 --- /dev/null +++ b/providers/gusto/test/write/employee-create.json @@ -0,0 +1,8 @@ +{ + "uuid": "emp_001", + "first_name": "Alice", + "last_name": "Anderson", + "email": "alice@example.com", + "company_uuid": "test-company-uuid", + "version": "v1" +} diff --git a/providers/gusto/test/write/employee-update.json b/providers/gusto/test/write/employee-update.json new file mode 100644 index 000000000..cc438536d --- /dev/null +++ b/providers/gusto/test/write/employee-update.json @@ -0,0 +1,8 @@ +{ + "uuid": "emp_001", + "first_name": "Alicia", + "last_name": "Anderson", + "email": "alice@example.com", + "company_uuid": "test-company-uuid", + "version": "v2" +} diff --git a/providers/gusto/test/write/job-create.json b/providers/gusto/test/write/job-create.json new file mode 100644 index 000000000..410c0f411 --- /dev/null +++ b/providers/gusto/test/write/job-create.json @@ -0,0 +1,6 @@ +{ + "uuid": "job_001", + "employee_uuid": "emp_001", + "title": "Software Engineer", + "version": "v1" +} diff --git a/providers/gusto/test/write/location-update.json b/providers/gusto/test/write/location-update.json new file mode 100644 index 000000000..26ebd8b93 --- /dev/null +++ b/providers/gusto/test/write/location-update.json @@ -0,0 +1,8 @@ +{ + "uuid": "loc_001", + "street_1": "100 Main St", + "city": "San Francisco", + "state": "CA", + "zip": "94105", + "version": "v2" +} diff --git a/providers/gusto/write.go b/providers/gusto/write.go new file mode 100644 index 000000000..023715351 --- /dev/null +++ b/providers/gusto/write.go @@ -0,0 +1,327 @@ +package gusto + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/common/urlbuilder" + "github.com/amp-labs/connectors/internal/datautils" + "github.com/amp-labs/connectors/internal/jsonquery" +) + +// Gusto write API conventions: +// - CREATE: nested under parent +// POST /v1/companies/{company_id}/{object} (company-scoped) +// POST /v1/employees/{employee_id}/{object} (employee-scoped) +// POST /v1/jobs/{job_id}/{object} (job-scoped — compensations only) +// - UPDATE: top-level by UUID +// PUT /v1/{object}/{uuid} +// Every PUT requires a `version` field in the body for optimistic +// concurrency control. Callers must include it; we do not synthesize it. +// - DELETE: not exposed by Gusto for the objects covered here. Most +// resources prefer "deactivate" semantics (e.g., `terminations`, +// `inactive=true`) over hard delete. Live testing will surface any +// gaps; until then this connector is Write-only. +// +// Workflow operations (payroll calculate/submit/cancel, time-off +// approve/reject, etc.) are out of scope here — they don't fit the +// (objectName, recordId, recordData) shape of WriteConnector. Match the +// codebase precedent: Stripe charges/captures and QuickBooks invoice +// void/send are also proxy-only. Gusto's Proxy support is already enabled +// in providers/gusto.go. +// +// API references (slug = {method}-v1-{path-with-dashes}): +// https://docs.gusto.com/app-integrations/reference/ +// - put-v1-employees +// - put-v1-locations +// - put-v1-companies +// - put-v1-compensations-compensation_id +// - post-v1-employees +// - post-v1-companies-company_id-locations +// - post-v1-companies-company_uuid-contractors +// - post-v1-employees-employee_id-employee_benefits +// - post-v1-employees-employee_id-home_addresses +// - post-v1-employees-employee_id-work_addresses +// - post-v1-companies-company_id-earning_types +// - post-v1-companies-company_id-company_benefits + +// Path-template parent-id keys carried in RecordData for nested creates. +// Callers POSTing employee-scoped or job-scoped objects must include the +// matching key in their payload; the connector pulls it into the URL path +// and removes it from the body before sending. +const ( + parentIDKeyEmployeeID = "employee_id" + parentIDKeyJobID = "job_id" +) + +var errMissingParentID = errors.New("gusto: missing parent id in record data") + +// objectCompensations is write-only: read-side excludes it (would require +// employees → jobs → compensations 2-level traversal). Writes target it +// directly via /v1/jobs/{job_id}/compensations + PUT /v1/compensations/{uuid}. +const objectCompensations = "compensations" + +// Write-supported objects, classified by parent scope at create time. +// +//nolint:gochecknoglobals +var ( + // companyScopedCreate creates under POST /v1/companies/{company_id}/{object}. + // company_id comes from the connector struct (set at construction time). + // + // objectCustomFields is intentionally absent: Gusto's public API exposes + // only GET endpoints for custom fields (definitions + per-employee values). + // Definitions are managed through Gusto's admin UI, not the API. Verified + // against the Gusto docs sitemap (App Integrations + Embedded Payroll + // tracks). See providers/gusto/custom.go for the read-side handling. + companyScopedCreate = datautils.NewStringSet( + objectEmployees, + objectLocations, + objectDepartments, + objectContractors, + objectPayrolls, + objectPaySchedules, + objectEarningTypes, + objectCompanyBenefits, + objectAdmins, + objectContractorPayments, + ) + + // employeeScopedCreate creates under POST /v1/employees/{employee_id}/{object}. + // employee_id is extracted from RecordData. + employeeScopedCreate = datautils.NewStringSet( + objectJobs, + objectEmployeeBenefits, + objectGarnishments, + objectHomeAddresses, + objectWorkAddresses, + ) + + // jobScopedCreate creates under POST /v1/jobs/{job_id}/{object}. + // job_id is extracted from RecordData. + jobScopedCreate = datautils.NewStringSet( + objectCompensations, + ) + + // updateOnly objects expose only PUT (top-level entities not created via the API). + updateOnly = datautils.NewStringSet( + objectCompanies, + ) + + // createOnly objects expose only POST. Most one-shot Gusto resources fall + // here; Gusto either has no PUT for them or the operation is undocumented. + createOnly = datautils.NewStringSet( + objectAdmins, + objectContractorPayments, + ) + + // companyScopedUpdate objects use a nested URL for PUT — + // PUT /v1/companies/{company_id}/{object}/{record_id} — instead of the + // flat PUT /v1/{object}/{record_id} pattern used by employees, locations, + // jobs, etc. Confirmed via sitemap entries: + // put-v1-companies-company_id-earning_types-earning_type_uuid + // put-v1-companies-company_id-pay_schedules-pay_schedule_id + // put-v1-companies-company_id-payrolls + companyScopedUpdate = datautils.NewStringSet( + objectEarningTypes, + objectPaySchedules, + objectPayrolls, + ) + + // allWriteSupported is the union of every set above. Used by + // validateWriteParams for a single membership check rather than 4 ORed + // .Has() calls. + allWriteSupported = datautils.MergeSets( + companyScopedCreate, + employeeScopedCreate, + jobScopedCreate, + updateOnly, + ) +) + +func (c *Connector) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) { + if err := validateWriteParams(params); err != nil { + return nil, err + } + + record, err := common.RecordDataToMap(params.RecordData) + if err != nil { + return nil, err + } + + url, method, err := c.buildWriteURL(params, record) + if err != nil { + return nil, err + } + + body, err := json.Marshal(record) + if err != nil { + return nil, fmt.Errorf("marshal record data: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, method, url.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + return req, nil +} + +// validateWriteParams rejects unsupported objects and operations early. +func validateWriteParams(params common.WriteParams) error { + if !allWriteSupported.Has(params.ObjectName) { + return common.ErrOperationNotSupportedForObject + } + + if params.IsCreate() && updateOnly.Has(params.ObjectName) { + return common.ErrOperationNotSupportedForObject + } + + if params.IsUpdate() && createOnly.Has(params.ObjectName) { + return common.ErrOperationNotSupportedForObject + } + + return nil +} + +// buildWriteURL routes to one of: +// - PUT /v1/{object}/{recordId} (top-level update) +// - PUT /v1/companies/{company_id}/{object}/{recordId} (company-scoped update) +// - POST /v1/companies/{company_id}/{object} (company-scoped create) +// - POST /v1/employees/{employee_id}/{object} (employee-scoped create) +// - POST /v1/jobs/{job_id}/{object} (job-scoped create) +// +// For nested creates, the parent ID is pulled from `record` (and removed so it +// is not echoed back into the request body). +func (c *Connector) buildWriteURL( + params common.WriteParams, record map[string]any, +) (*urlbuilder.URL, string, error) { + if params.IsUpdate() { + return c.buildUpdateURL(params) + } + + return c.buildCreateURL(params, record) +} + +// buildUpdateURL handles PUT routing — flat top-level for most objects, nested +// under company for the few Gusto exposes that way (earning_types, +// pay_schedules, payrolls). +func (c *Connector) buildUpdateURL(params common.WriteParams) (*urlbuilder.URL, string, error) { + baseURL := c.ProviderInfo().BaseURL + + if companyScopedUpdate.Has(params.ObjectName) { + if c.companyID == "" { + return nil, "", ErrMissingCompanyID + } + + u, err := urlbuilder.New(baseURL, "v1", "companies", c.companyID, params.ObjectName, params.RecordId) + + return u, http.MethodPut, err + } + + u, err := urlbuilder.New(baseURL, "v1", params.ObjectName, params.RecordId) + + return u, http.MethodPut, err +} + +// buildCreateURL handles POST routing across the three create scopes. +func (c *Connector) buildCreateURL( + params common.WriteParams, record map[string]any, +) (*urlbuilder.URL, string, error) { + baseURL := c.ProviderInfo().BaseURL + + switch { + case companyScopedCreate.Has(params.ObjectName): + if c.companyID == "" { + return nil, "", ErrMissingCompanyID + } + + u, err := urlbuilder.New(baseURL, "v1", "companies", c.companyID, params.ObjectName) + + return u, http.MethodPost, err + + case employeeScopedCreate.Has(params.ObjectName): + employeeID, ok := stringFromRecord(record, parentIDKeyEmployeeID) + if !ok { + return nil, "", fmt.Errorf("%w: %s", errMissingParentID, parentIDKeyEmployeeID) + } + + u, err := urlbuilder.New(baseURL, "v1", "employees", employeeID, params.ObjectName) + + return u, http.MethodPost, err + + case jobScopedCreate.Has(params.ObjectName): + jobID, ok := stringFromRecord(record, parentIDKeyJobID) + if !ok { + return nil, "", fmt.Errorf("%w: %s", errMissingParentID, parentIDKeyJobID) + } + + u, err := urlbuilder.New(baseURL, "v1", "jobs", jobID, params.ObjectName) + + return u, http.MethodPost, err + + default: + return nil, "", common.ErrOperationNotSupportedForObject + } +} + +// stringFromRecord pulls a parent-id from RecordData and removes it from the +// map so it is not echoed back into the request body. Returns false if the +// key is missing or empty. +func stringFromRecord(record map[string]any, key string) (string, bool) { + raw, ok := record[key] + if !ok { + return "", false + } + + str, ok := raw.(string) + if !ok || str == "" { + return "", false + } + + delete(record, key) + + return str, true +} + +// parseWriteResponse extracts the record's UUID (Gusto's primary key) and +// returns the full response body as Data. PUTs that return 200 with the +// updated object echo through the same path. On 204 (no body), the caller's +// RecordId is echoed for correlation. +func (c *Connector) parseWriteResponse( + _ context.Context, + params common.WriteParams, + _ *http.Request, + response *common.JSONHTTPResponse, +) (*common.WriteResult, error) { + body, ok := response.Body() + if !ok { + return &common.WriteResult{ + Success: true, + RecordId: params.RecordId, + }, nil + } + + recordID, err := jsonquery.New(body).StrWithDefault("uuid", params.RecordId) + if err != nil { + return nil, err + } + + data, err := jsonquery.Convertor.ObjectToMap(body) + if err != nil { + return nil, err + } + + return &common.WriteResult{ + Success: true, + RecordId: recordID, + Data: data, + }, nil +} diff --git a/providers/gusto/write_test.go b/providers/gusto/write_test.go new file mode 100644 index 000000000..12babf90c --- /dev/null +++ b/providers/gusto/write_test.go @@ -0,0 +1,342 @@ +package gusto + +import ( + _ "embed" + "net/http" + "testing" + + "github.com/amp-labs/connectors" + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/test/utils/mockutils" + "github.com/amp-labs/connectors/test/utils/mockutils/mockcond" + "github.com/amp-labs/connectors/test/utils/mockutils/mockserver" + "github.com/amp-labs/connectors/test/utils/testroutines" +) + +//go:embed test/write/employee-create.json +var employeeCreateResponse []byte + +//go:embed test/write/employee-update.json +var employeeUpdateResponse []byte + +//go:embed test/write/job-create.json +var jobCreateResponse []byte + +//go:embed test/write/compensation-create.json +var compensationCreateResponse []byte + +//go:embed test/write/location-update.json +var locationUpdateResponse []byte + +//go:embed test/write/earning-type-update.json +var earningTypeUpdateResponse []byte + +func TestWrite(t *testing.T) { //nolint:funlen,maintidx + t.Parallel() + + tests := []testroutines.Write{ + { + Name: "Write object must be included", + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrMissingObjects}, + }, + { + Name: "Object must be supported", + Input: common.WriteParams{ + ObjectName: "users", + RecordData: map[string]any{"name": "test"}, + }, + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrOperationNotSupportedForObject}, + }, + { + Name: "Update on companies returns the updated record", + // companies is updateOnly — no create allowed. + Input: common.WriteParams{ + ObjectName: "companies", + RecordData: map[string]any{"contractor_only": false}, + }, + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrOperationNotSupportedForObject}, + }, + { + Name: "Update on admins is not supported", + // admins is createOnly — Gusto does not expose PUT. + Input: common.WriteParams{ + ObjectName: "admins", + RecordId: "adm_001", + RecordData: map[string]any{"first_name": "x", "version": "v1"}, + }, + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrOperationNotSupportedForObject}, + }, + { + Name: "Create employee succeeds — companyID injected from connector metadata", + Input: common.WriteParams{ + ObjectName: "employees", + RecordData: map[string]any{ + "first_name": "Alice", + "last_name": "Anderson", + "email": "alice@example.com", + }, + }, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodPOST(), + mockcond.Path("/v1/companies/" + testCompanyID + "/employees"), + }, + Then: mockserver.Response(http.StatusCreated, employeeCreateResponse), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected"}`), + }.Server(), + Comparator: testroutines.ComparatorSubsetWrite, + Expected: &common.WriteResult{ + Success: true, + RecordId: "emp_001", + Data: map[string]any{ + "uuid": "emp_001", + "first_name": "Alice", + }, + }, + }, + { + Name: "Update employee succeeds — top-level URL by uuid", + // PUT requires `version` field in RecordData; we don't synthesize it. + Input: common.WriteParams{ + ObjectName: "employees", + RecordId: "emp_001", + RecordData: map[string]any{ + "first_name": "Alicia", + "version": "v1", + }, + }, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodPUT(), + mockcond.Path("/v1/employees/emp_001"), + }, + Then: mockserver.Response(http.StatusOK, employeeUpdateResponse), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected"}`), + }.Server(), + Comparator: testroutines.ComparatorSubsetWrite, + Expected: &common.WriteResult{ + Success: true, + RecordId: "emp_001", + Data: map[string]any{ + "uuid": "emp_001", + "first_name": "Alicia", + "version": "v2", + }, + }, + }, + { + Name: "Update location succeeds — top-level URL by uuid", + Input: common.WriteParams{ + ObjectName: "locations", + RecordId: "loc_001", + RecordData: map[string]any{ + "city": "San Francisco", + "version": "v1", + }, + }, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodPUT(), + mockcond.Path("/v1/locations/loc_001"), + }, + Then: mockserver.Response(http.StatusOK, locationUpdateResponse), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected"}`), + }.Server(), + Comparator: testroutines.ComparatorSubsetWrite, + Expected: &common.WriteResult{ + Success: true, + RecordId: "loc_001", + Data: map[string]any{ + "uuid": "loc_001", + "version": "v2", + }, + }, + }, + { + Name: "Update earning_type uses company-scoped URL — PUT /v1/companies/{cid}/earning_types/{uuid}", + // earning_types, pay_schedules, and payrolls have a nested PUT URL + // per Gusto's docs (sitemap entry put-v1-companies-company_id-earning_types-earning_type_uuid). + // All other top-level updates flatten to PUT /v1/{object}/{uuid}. + Input: common.WriteParams{ + ObjectName: "earning_types", + RecordId: "et_001", + RecordData: map[string]any{ + "name": "Bonus Updated", + "version": "v1", + }, + }, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodPUT(), + mockcond.Path("/v1/companies/" + testCompanyID + "/earning_types/et_001"), + }, + Then: mockserver.Response(http.StatusOK, earningTypeUpdateResponse), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected"}`), + }.Server(), + Comparator: testroutines.ComparatorSubsetWrite, + Expected: &common.WriteResult{ + Success: true, + RecordId: "et_001", + Data: map[string]any{ + "uuid": "et_001", + "name": "Bonus Updated", + "version": "v2", + }, + }, + }, + { + Name: "Update earning_type without companyID returns ErrMissingCompanyID", + // Same nested-URL path requires companyID metadata. Sanity check that + // we error early rather than constructing a malformed URL. + Input: common.WriteParams{ + ObjectName: "earning_types", + RecordId: "et_001", + RecordData: map[string]any{"name": "x", "version": "v1"}, + }, + Server: mockserver.Dummy(), + ExpectedErrs: []error{ErrMissingCompanyID}, + }, + { + Name: "Create job — employee_id extracted from RecordData and stripped from body", + Input: common.WriteParams{ + ObjectName: "jobs", + RecordData: map[string]any{ + "employee_id": "emp_001", + "title": "Software Engineer", + }, + }, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodPOST(), + mockcond.Path("/v1/employees/emp_001/jobs"), + }, + Then: mockserver.Response(http.StatusCreated, jobCreateResponse), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected"}`), + }.Server(), + Comparator: testroutines.ComparatorSubsetWrite, + Expected: &common.WriteResult{ + Success: true, + RecordId: "job_001", + Data: map[string]any{ + "uuid": "job_001", + }, + }, + }, + { + Name: "Create job missing employee_id returns errMissingParentID", + Input: common.WriteParams{ + ObjectName: "jobs", + RecordData: map[string]any{"title": "Software Engineer"}, + }, + Server: mockserver.Dummy(), + ExpectedErrs: []error{errMissingParentID}, + }, + { + Name: "Create compensation — job_id extracted from RecordData", + Input: common.WriteParams{ + ObjectName: "compensations", + RecordData: map[string]any{ + "job_id": "job_001", + "rate": "100.00", + "payment_unit": "Hour", + }, + }, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodPOST(), + mockcond.Path("/v1/jobs/job_001/compensations"), + }, + Then: mockserver.Response(http.StatusCreated, compensationCreateResponse), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected"}`), + }.Server(), + Comparator: testroutines.ComparatorSubsetWrite, + Expected: &common.WriteResult{ + Success: true, + RecordId: "comp_001", + Data: map[string]any{ + "uuid": "comp_001", + }, + }, + }, + { + Name: "Create employee without companyID returns ErrMissingCompanyID", + Input: common.WriteParams{ + ObjectName: "employees", + RecordData: map[string]any{"first_name": "Alice"}, + }, + Server: mockserver.Dummy(), + ExpectedErrs: []error{ErrMissingCompanyID}, + }, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + + tt.Run(t, func() (connectors.WriteConnector, error) { + // Cases that test ErrMissingCompanyID explicitly omit the metadata. + switch tt.Name { + case "Create employee without companyID returns ErrMissingCompanyID", + "Update earning_type without companyID returns ErrMissingCompanyID": + return constructTestWriteConnector(tt.Server.URL, "") + } + + return constructTestWriteConnector(tt.Server.URL, testCompanyID) + }) + }) + } +} + +func constructTestWriteConnector(baseURL, companyID string) (*Connector, error) { + meta := map[string]string{} + if companyID != "" { + meta[metadataKeyCompanyID] = companyID + } + + conn, err := NewConnector(common.ConnectorParams{ + Module: common.ModuleRoot, + AuthenticatedClient: mockutils.NewClient(), + Metadata: meta, + }) + if err != nil { + return nil, err + } + + conn.SetUnitTestBaseURL(baseURL) + + return conn, nil +} diff --git a/test/gusto/write/main.go b/test/gusto/write/main.go new file mode 100644 index 000000000..2d30869d3 --- /dev/null +++ b/test/gusto/write/main.go @@ -0,0 +1,481 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/amp-labs/connectors" + "github.com/amp-labs/connectors/common" + gustoConn "github.com/amp-labs/connectors/providers/gusto" + connTest "github.com/amp-labs/connectors/test/gusto" + "github.com/amp-labs/connectors/test/utils" +) + +// Gusto write live test — broad coverage of every object the connector +// supports, exercising both the CREATE and UPDATE code paths where Gusto +// allows them. +// +// Idempotent across re-runs: +// - All parent UUIDs (employee, location, company) discovered at runtime +// via Read; nothing hardcoded. +// - Unique-per-run values (timestamp suffixes, future effective_dates) are +// used so Gusto's uniqueness constraints don't fail back-to-back runs. +// - Versions for PUT calls come from the record we just created (fresh +// state every run) rather than the read snapshot. +// +// Endpoints exercised: +// Company-scoped CREATE (POST /v1/companies/{cid}/{object}): +// 1. departments +// 2. locations +// 3. earning_types +// Employee-scoped CREATE (POST /v1/employees/{eid}/{object}; eid from RecordData): +// 4. home_addresses +// 5. work_addresses +// 6. jobs +// 7. garnishments +// Job-scoped CREATE (POST /v1/jobs/{jid}/{object}; jid from RecordData): +// 8. compensations +// Top-level UPDATE (PUT /v1/{object}/{uuid}; requires version): +// 9. departments (just-created uuid+version) +// 10. earning_types (just-created uuid+version) +// 11. jobs (just-created uuid+version) +// 12. compensations (just-created uuid+version) +// 13. locations (existing, version refreshed each run) +// 14. employees (existing first employee) + +func main() { //nolint:funlen + ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer done() + + utils.SetupLogging() + + conn := connTest.GetConnector(ctx) + stamp := time.Now().Format("20060102-150405") + + // Discover parents we'll use throughout the run. + employee, err := firstRecord(ctx, conn, "employees", "uuid", "version") + if err != nil { + bail("discover employee", err) + } + + location, err := firstRecord(ctx, conn, "locations", "uuid", "version") + if err != nil { + bail("discover location", err) + } + + // === Company-scoped creates === + deptUUID, deptVersion, err := createDepartment(ctx, conn, stamp) + if err != nil { + bail("create department", err) + } + + if _, _, err := createLocation(ctx, conn, stamp); err != nil { + bail("create location", err) + } + + earningTypeUUID, _, err := createEarningType(ctx, conn, stamp) + if err != nil { + bail("create earning_type", err) + } + + // === Employee-scoped creates (employee_id pulled from RecordData) === + if _, _, err := createHomeAddress(ctx, conn, employee["uuid"]); err != nil { + bail("create home_address", err) + } + + if _, _, err := createWorkAddress(ctx, conn, employee["uuid"], location["uuid"]); err != nil { + bail("create work_address", err) + } + + jobUUID, jobVersion, err := createJob(ctx, conn, employee["uuid"], stamp) + if err != nil { + bail("create job", err) + } + + if _, _, err := createGarnishment(ctx, conn, employee["uuid"], stamp); err != nil { + bail("create garnishment", err) + } + + // Update the job BEFORE creating a compensation, because creating a + // compensation bumps the parent job's version (Gusto invariant). + if err := updateJob(ctx, conn, jobUUID, jobVersion, stamp); err != nil { + bail("update job", err) + } + + // === Job-scoped create (job_id pulled from RecordData) === + compensationUUID, compensationVersion, err := createCompensation(ctx, conn, jobUUID, stamp) + if err != nil { + bail("create compensation", err) + } + + // === Top-level / company-scoped updates === + if err := updateDepartment(ctx, conn, deptUUID, deptVersion, stamp); err != nil { + bail("update department", err) + } + + if err := updateEarningType(ctx, conn, earningTypeUUID, stamp); err != nil { + bail("update earning_type", err) + } + + if err := updateCompensation(ctx, conn, compensationUUID, compensationVersion); err != nil { + bail("update compensation", err) + } + + if err := updateLocation(ctx, conn, location["uuid"], location["version"]); err != nil { + bail("update location", err) + } + + if err := updateEmployee(ctx, conn, employee["uuid"], employee["version"], stamp); err != nil { + bail("update employee", err) + } + + // === Delete (cleanup) — exercises the Deleter code paths and keeps the + // demo company tidy by removing every record this run created. Order + // matters: compensation before its parent job; nothing else has hard + // foreign-key dependencies. === + if err := deleteRecord(ctx, conn, "compensations", compensationUUID); err != nil { + bail("delete compensation", err) + } + + if err := deleteRecord(ctx, conn, "jobs", jobUUID); err != nil { + bail("delete job", err) + } + + if err := deleteRecord(ctx, conn, "departments", deptUUID); err != nil { + bail("delete department", err) + } + + if err := deleteRecord(ctx, conn, "earning_types", earningTypeUUID); err != nil { + // company-scoped DELETE — verifies the nested URL path + bail("delete earning_type", err) + } + + slog.Info("=== ALL WRITE SCENARIOS PASSED ===") +} + +// deleteRecord exercises the Delete code path. Top-level vs company-scoped +// routing is decided inside the connector based on objectName. +func deleteRecord(ctx context.Context, conn *gustoConn.Connector, objectName, uuid string) error { + slog.Info("=== Delete "+objectName+" ===", "uuid", uuid) + + res, err := conn.Delete(ctx, common.DeleteParams{ + ObjectName: objectName, + RecordId: uuid, + }) + if err != nil { + return err + } + + if res == nil || !res.Success { + return fmt.Errorf("delete %s returned non-success result", objectName) + } + + utils.DumpJSON(map[string]any{ + "success": true, + "deleted": uuid, + "objectName": objectName, + }, os.Stdout) + + return nil +} + +// firstRecord reads the first item of an object and returns the requested +// fields from its Raw payload as a map. +func firstRecord(ctx context.Context, conn *gustoConn.Connector, objectName string, fields ...string) (map[string]string, error) { + res, err := conn.Read(ctx, common.ReadParams{ + ObjectName: objectName, + Fields: connectors.Fields(fields...), + PageSize: 1, + }) + if err != nil { + return nil, err + } + + if res == nil || len(res.Data) == 0 { + return nil, fmt.Errorf("no %s in demo company", objectName) + } + + out := make(map[string]string, len(fields)) + for _, f := range fields { + v, _ := res.Data[0].Raw[f].(string) + out[f] = v + } + + slog.Info("discovered "+objectName, slog.Any("fields", out)) + + return out, nil +} + +// ---- Company-scoped creates ---- + +func createDepartment(ctx context.Context, conn *gustoConn.Connector, stamp string) (string, string, error) { + slog.Info("=== Create department (company-scoped) ===") + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "departments", + RecordData: map[string]any{"title": "QA Engineering " + stamp}, + }) + + return capture(res, err) +} + +func createLocation(ctx context.Context, conn *gustoConn.Connector, stamp string) (string, string, error) { + slog.Info("=== Create location (company-scoped) ===") + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "locations", + RecordData: map[string]any{ + "street_1": "100 Test Plaza " + stamp[len(stamp)-6:], + "city": "San Francisco", + "state": "CA", + "zip": "94105", + "country": "USA", + "phone_number": "4155550100", + }, + }) + + return capture(res, err) +} + +func createEarningType(ctx context.Context, conn *gustoConn.Connector, stamp string) (string, string, error) { + slog.Info("=== Create earning_type (company-scoped) ===") + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "earning_types", + RecordData: map[string]any{"name": "Bonus " + stamp}, + }) + + return capture(res, err) +} + +// ---- Employee-scoped creates ---- + +func createHomeAddress(ctx context.Context, conn *gustoConn.Connector, employeeUUID string) (string, string, error) { + slog.Info("=== Create home_address (employee-scoped) ===") + + // Gusto enforces (employee, effective_date) uniqueness; offset by ms-of-now. + daysOffset := int(time.Now().UnixMilli()) % 1000 + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "home_addresses", + RecordData: map[string]any{ + "employee_id": employeeUUID, + "street_1": fmt.Sprintf("Home Lane #%d", daysOffset), + "city": "San Francisco", + "state": "CA", + "zip": "94105", + "effective_date": time.Now().AddDate(0, 0, daysOffset+1).Format("2006-01-02"), + }, + }) + + return capture(res, err) +} + +func createWorkAddress(ctx context.Context, conn *gustoConn.Connector, employeeUUID, locationUUID string) (string, string, error) { + slog.Info("=== Create work_address (employee-scoped) ===") + + daysOffset := int(time.Now().UnixMilli()/2) % 1000 + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "work_addresses", + RecordData: map[string]any{ + "employee_id": employeeUUID, + "location_uuid": locationUUID, + "effective_date": time.Now().AddDate(0, 0, daysOffset+1).Format("2006-01-02"), + }, + }) + + return capture(res, err) +} + +func createJob(ctx context.Context, conn *gustoConn.Connector, employeeUUID, stamp string) (string, string, error) { + slog.Info("=== Create job (employee-scoped) ===") + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "jobs", + RecordData: map[string]any{ + "employee_id": employeeUUID, + "title": "Test Engineer " + stamp, + "hire_date": time.Now().Format("2006-01-02"), + "primary": false, + "location_uuid": nil, + "two_percent_shareholder": false, + }, + }) + + return capture(res, err) +} + +func createGarnishment(ctx context.Context, conn *gustoConn.Connector, employeeUUID, stamp string) (string, string, error) { + slog.Info("=== Create garnishment (employee-scoped) ===") + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "garnishments", + RecordData: map[string]any{ + "employee_id": employeeUUID, + "active": true, + "amount": "150.00", + "description": "Test garnishment " + stamp, + "court_ordered": true, + "times": 5, + "recurring": false, + }, + }) + + return capture(res, err) +} + +// ---- Job-scoped create ---- + +func createCompensation(ctx context.Context, conn *gustoConn.Connector, jobUUID, stamp string) (string, string, error) { + slog.Info("=== Create compensation (job-scoped) ===") + + // Gusto requires compensation effective_date in [tomorrow, +1 year]. + // Pick a unique-per-run day inside that window. + daysOffset := int(time.Now().UnixMilli()/1000)%364 + 1 + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "compensations", + RecordData: map[string]any{ + "job_id": jobUUID, + "rate": "75.00", + "payment_unit": "Hour", + "flsa_status": "Nonexempt", + "effective_date": time.Now().AddDate(0, 0, daysOffset).Format("2006-01-02"), + }, + }) + + return capture(res, err) +} + +// ---- Top-level updates ---- + +func updateDepartment(ctx context.Context, conn *gustoConn.Connector, uuid, version, stamp string) error { + slog.Info("=== Update department (top-level PUT) ===", "uuid", uuid) + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "departments", + RecordId: uuid, + RecordData: map[string]any{ + "version": version, + "title": "QA Engineering Updated " + stamp, + }, + }) + + return only(res, err) +} + +func updateEarningType(ctx context.Context, conn *gustoConn.Connector, uuid, stamp string) error { + slog.Info("=== Update earning_type (top-level PUT) ===", "uuid", uuid) + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "earning_types", + RecordId: uuid, + RecordData: map[string]any{"name": "Bonus Updated " + stamp}, + }) + + return only(res, err) +} + +func updateJob(ctx context.Context, conn *gustoConn.Connector, uuid, version, stamp string) error { + slog.Info("=== Update job (top-level PUT) ===", "uuid", uuid) + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "jobs", + RecordId: uuid, + RecordData: map[string]any{ + "version": version, + "title": "Senior Test Engineer " + stamp, + }, + }) + + return only(res, err) +} + +func updateCompensation(ctx context.Context, conn *gustoConn.Connector, uuid, version string) error { + slog.Info("=== Update compensation (top-level PUT) ===", "uuid", uuid) + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "compensations", + RecordId: uuid, + RecordData: map[string]any{ + "version": version, + "rate": "85.00", + }, + }) + + return only(res, err) +} + +func updateLocation(ctx context.Context, conn *gustoConn.Connector, uuid, version string) error { + slog.Info("=== Update location (top-level PUT) ===", "uuid", uuid) + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "locations", + RecordId: uuid, + RecordData: map[string]any{ + "version": version, + "phone_number": "4155551234", + }, + }) + + return only(res, err) +} + +func updateEmployee(ctx context.Context, conn *gustoConn.Connector, uuid, version, stamp string) error { + slog.Info("=== Update employee (top-level PUT) ===", "uuid", uuid) + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "employees", + RecordId: uuid, + RecordData: map[string]any{ + "version": version, + "middle_initial": "T", + "preferred_first_name": "Test " + stamp[len(stamp)-4:], + }, + }) + + return only(res, err) +} + +// ---- helpers ---- + +// capture prints a successful WriteResult and extracts uuid + version from +// the data payload so the caller can use them in subsequent updates. +func capture(res *common.WriteResult, err error) (string, string, error) { + if err != nil { + return "", "", err + } + + utils.DumpJSON(res, os.Stdout) + + if res == nil || res.Data == nil { + return "", "", nil + } + + uuid, _ := res.Data["uuid"].(string) + version, _ := res.Data["version"].(string) + + return uuid, version, nil +} + +// only is for endpoints whose return values we don't reuse downstream. +func only(res *common.WriteResult, err error) error { + if err != nil { + return err + } + + utils.DumpJSON(res, os.Stdout) + + return nil +} + +func bail(label string, err error) { + slog.Error(label, "err", err) + os.Exit(1) +}