diff --git a/connector/new.go b/connector/new.go index 1ef93543f..664b8e2e9 100644 --- a/connector/new.go +++ b/connector/new.go @@ -209,6 +209,7 @@ var connectorConstructors = map[providers.Provider]outputConstructorFunc{ // nol providers.Granola: wrapper(newGranolaConnector), providers.Groove: wrapper(newGrooveConnector), providers.Gusto: wrapper(newGustoConnector), + providers.GustoDemo: wrapper(newGustoDemoConnector), providers.HappyFox: wrapper(newHappyFoxConnector), providers.HelpScoutMailbox: wrapper(newHelpScoutMailboxConnector), providers.HeyReach: wrapper(newHeyReachConnector), @@ -824,6 +825,12 @@ func newGustoConnector( return gusto.NewConnector(params) } +func newGustoDemoConnector( + params common.ConnectorParams, +) (*gusto.Connector, error) { + return gusto.NewDemoConnector(params) +} + func newPinterestConnector( params common.ConnectorParams, ) (*pinterest.Connector, error) { diff --git a/providers/gusto/connector.go b/providers/gusto/connector.go index 8c3d82746..b14fa047b 100644 --- a/providers/gusto/connector.go +++ b/providers/gusto/connector.go @@ -7,31 +7,61 @@ package gusto import ( "github.com/amp-labs/connectors/common" "github.com/amp-labs/connectors/internal/components" + "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/providers" "github.com/amp-labs/connectors/providers/gusto/metadata" ) +// metadataKeyCompanyID is the key under ConnectorParams.Metadata that must +// contain the Gusto company UUID the installation is scoped to. +const metadataKeyCompanyID = "companyId" + // Connector is the Gusto connector. type Connector struct { *components.Connector common.RequireAuthenticatedClient components.SchemaProvider + components.Reader + + companyID string } -// NewConnector creates a new Gusto connector. +// NewConnector creates a new Gusto connector for the production environment. func NewConnector(params common.ConnectorParams) (*Connector, error) { - return components.Initialize(providers.Gusto, params, constructor) + return components.Initialize(providers.Gusto, params, constructor(params)) } -func constructor(base *components.Connector) (*Connector, error) { - connector := &Connector{Connector: base} +// NewDemoConnector creates a new Gusto connector for the sandbox/demo environment. +func NewDemoConnector(params common.ConnectorParams) (*Connector, error) { + return components.Initialize(providers.GustoDemo, params, constructor(params)) +} + +func constructor(params common.ConnectorParams) func(*components.Connector) (*Connector, error) { + return func(base *components.Connector) (*Connector, error) { + connector := &Connector{ + Connector: base, + companyID: params.Metadata[metadataKeyCompanyID], + } + + connector.SchemaProvider = schema.NewOpenAPISchemaProvider( + connector.ProviderContext.Module(), + metadata.Schemas, + ) - connector.SchemaProvider = schema.NewOpenAPISchemaProvider( - connector.ProviderContext.Module(), - metadata.Schemas, - ) + connector.Reader = reader.NewHTTPReader( + connector.HTTPClient().Client, + components.NewEmptyEndpointRegistry(), + connector.ProviderContext.Module(), + operations.ReadHandlers{ + BuildRequest: connector.buildReadRequest, + ParseResponse: connector.parseReadResponse, + ErrorHandler: common.InterpretError, + }, + ) - return connector, nil + return connector, nil + } } diff --git a/providers/gusto/read.go b/providers/gusto/read.go new file mode 100644 index 000000000..98090a51e --- /dev/null +++ b/providers/gusto/read.go @@ -0,0 +1,420 @@ +package gusto + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/common/readhelper" + "github.com/amp-labs/connectors/common/urlbuilder" + "github.com/amp-labs/connectors/internal/datautils" + "github.com/amp-labs/connectors/internal/jsonquery" + "github.com/amp-labs/connectors/internal/simultaneously" + "github.com/amp-labs/connectors/providers/gusto/metadata" + "github.com/spyzhov/ajson" +) + +const ( + // Pagination: + // - Gusto default per-page is 25 (per docs), but no hard maximum is documented. + // - 100 works reliably per live tests and is what we use as both default and cap. + // - We cap because our "records < per" end-of-pages check would incorrectly + // stop paginating if Gusto silently caps server-side below the requested value. + defaultPageSize = "100" + maxPageSize = 100 + pageParam = "page" + perParam = "per" + companyIDPlaceholder = "{company_id}" + + // Gusto rate limit: 200 req/min. 4 concurrent child requests per employee + // page is conservative and well within that limit. + maxConcurrentEmployeeFetch = 4 +) + +// Object name constants — all objects readable via this connector. +const ( + objectAdmins = "admins" + objectCompanies = "companies" + objectCompanyBenefits = "company_benefits" + objectContractorPayments = "contractor_payments" + objectContractors = "contractors" + objectCustomFields = "custom_fields" + objectDepartments = "departments" + objectEarningTypes = "earning_types" + objectEmployees = "employees" + objectLocations = "locations" + objectPayPeriods = "pay_periods" + objectPaySchedules = "pay_schedules" + objectPayrolls = "payrolls" + // Employee-scoped: reading these requires fetching all employees first, + // then fanning out one request per employee to the child endpoint. + objectEmployeeBenefits = "employee_benefits" + objectGarnishments = "garnishments" + objectHomeAddresses = "home_addresses" + objectJobs = "jobs" + objectTimeOffActivities = "time_off_activities" + objectWorkAddresses = "work_addresses" +) + +// ErrMissingCompanyID is returned when the connector is constructed without the companyId metadata. +var ErrMissingCompanyID = errors.New("gusto: companyId metadata is required") + +// employeeScopedObjects are objects whose URL paths contain {employee_id}. +// Reading them requires first listing all employees in the company, then +// fanning out one request per employee to the child endpoint. The object +// name itself is used as the URL segment appended after /v1/employees/{uuid}/. +var employeeScopedObjects = datautils.NewSet( //nolint:gochecknoglobals + objectEmployeeBenefits, + objectGarnishments, + objectHomeAddresses, + objectJobs, + objectTimeOffActivities, + objectWorkAddresses, +) + +// supportedReadObjects is the complete set of objects this connector can read. +// compensations is excluded: it requires /jobs/{job_id}/compensations, a two-level +// parent lookup (employees → jobs → compensations) with no precedent in this repo. +var supportedReadObjects = datautils.NewSet( //nolint:gochecknoglobals + objectAdmins, + objectCompanies, + objectCompanyBenefits, + objectContractorPayments, + objectContractors, + objectCustomFields, + objectDepartments, + objectEarningTypes, + objectEmployees, + objectLocations, + objectPayPeriods, + objectPaySchedules, + objectPayrolls, + objectEmployeeBenefits, + objectGarnishments, + objectHomeAddresses, + objectJobs, + objectTimeOffActivities, + objectWorkAddresses, +) + +func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadParams) (*http.Request, error) { + if err := params.ValidateParams(true); err != nil { + return nil, err + } + + if !supportedReadObjects.Has(params.ObjectName) { + return nil, common.ErrOperationNotSupportedForObject + } + + apiURL, err := c.buildReadURL(params) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL.String(), nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + + return req, nil +} + +func (c *Connector) buildReadURL(params common.ReadParams) (*urlbuilder.URL, error) { + if len(params.NextPage) != 0 { + return urlbuilder.New(params.NextPage.String()) + } + + if employeeScopedObjects.Has(params.ObjectName) { + // Employee-scoped objects start by listing all employees. + // parseReadResponse fans out to the child endpoint per employee UUID. + return c.buildEmployeeListURL(params) + } + + path, err := metadata.Schemas.LookupURLPath(c.ProviderContext.Module(), params.ObjectName) + if err != nil { + return nil, err + } + + if strings.Contains(path, companyIDPlaceholder) { + if c.companyID == "" { + return nil, ErrMissingCompanyID + } + + path = strings.ReplaceAll(path, companyIDPlaceholder, c.companyID) + } + + apiURL, err := urlbuilder.New(c.ProviderInfo().BaseURL, path) + if err != nil { + return nil, err + } + + apiURL.WithQueryParam(perParam, pageSize(params)) + apiURL.WithQueryParam(pageParam, "1") + + return apiURL, nil +} + +// buildEmployeeListURL returns the paginated employees-list URL, reusing the +// employees path from the schema so URL construction is consistent. +func (c *Connector) buildEmployeeListURL(params common.ReadParams) (*urlbuilder.URL, error) { + if c.companyID == "" { + return nil, ErrMissingCompanyID + } + + path, err := metadata.Schemas.LookupURLPath(c.ProviderContext.Module(), objectEmployees) + if err != nil { + return nil, err + } + + path = strings.ReplaceAll(path, companyIDPlaceholder, c.companyID) + + apiURL, err := urlbuilder.New(c.ProviderInfo().BaseURL, path) + if err != nil { + return nil, err + } + + apiURL.WithQueryParam(perParam, pageSize(params)) + apiURL.WithQueryParam(pageParam, "1") + + return apiURL, nil +} + +func (c *Connector) parseReadResponse( + ctx context.Context, + params common.ReadParams, + request *http.Request, + resp *common.JSONHTTPResponse, +) (*common.ReadResult, error) { + if employeeScopedObjects.Has(params.ObjectName) { + return c.parseEmployeeScopedResponse(ctx, params, request.URL, resp) + } + + return common.ParseResultFiltered( + params, + resp, + c.recordsFunc(params.ObjectName), + readhelper.MakeIdentityFilterFunc(nextPageFromPageCounter(request.URL)), + common.MakeMarshaledDataFunc(nil), + params.Fields, + ) +} + +// pageSize returns the page size to request, respecting the user's +// params.PageSize but capping at maxPageSize. Capping protects the +// completion check in nextPageFromPageCounter: if Gusto silently +// caps the page size server-side below what we requested, the +// returned record count would be less than per and we would +// incorrectly stop paginating. +func pageSize(params common.ReadParams) string { + if params.PageSize <= 0 || params.PageSize > maxPageSize { + return strconv.Itoa(maxPageSize) + } + + return strconv.Itoa(params.PageSize) +} + +// recordsFunc returns the records extractor for the given object. +// All Gusto list endpoints return a bare JSON array at the root (no +// wrapper object), so schemas.json uses "responseKey": "" for every +// object. common.MakeRecordsFunc("") handles this via jsonquery's +// SelfReference semantics — the empty key means "the node itself". +func (c *Connector) recordsFunc(objectName string) common.NodeRecordsFunc { + return common.MakeRecordsFunc( + metadata.Schemas.LookupArrayFieldName(c.ProviderContext.Module(), objectName), + ) +} + +// parseEmployeeScopedResponse handles reads for objects whose paths require +// {employee_id}. It extracts employee UUIDs from the employee-list response, +// fans out one request per employee to the child endpoint, and returns the +// flattened records. Pagination advances through the employee list. +func (c *Connector) parseEmployeeScopedResponse( + ctx context.Context, + params common.ReadParams, + reqURL *url.URL, + resp *common.JSONHTTPResponse, +) (*common.ReadResult, error) { + body, ok := resp.Body() + if !ok { + return &common.ReadResult{Done: true}, nil + } + + nodes, err := c.recordsFunc(objectEmployees)(body) + if err != nil { + return nil, err + } + + data, err := c.fetchChildrenForEmployees(ctx, extractUUIDs(nodes), params) + if err != nil { + return nil, err + } + + nextPage, err := nextPageFromPageCounter(reqURL)(body) + if err != nil { + return nil, err + } + + return &common.ReadResult{ + Rows: int64(len(data)), + Data: data, + NextPage: common.NextPageToken(nextPage), + Done: nextPage == "", + }, nil +} + +// extractUUIDs returns the "uuid" string from each node, skipping nodes that +// don't have one. +func extractUUIDs(nodes []*ajson.Node) []string { + uuids := make([]string, 0, len(nodes)) + + for _, node := range nodes { + uuid, err := jsonquery.New(node).StringRequired("uuid") + if err != nil { + continue + } + + uuids = append(uuids, uuid) + } + + return uuids +} + +// fetchChildrenForEmployees fans out one request per employee UUID to the +// /v1/employees/{uuid}/{params.ObjectName} endpoint, preserves input order, +// and returns the flattened records. +func (c *Connector) fetchChildrenForEmployees( + ctx context.Context, uuids []string, params common.ReadParams, +) ([]common.ReadResultRow, error) { + childSuffix := params.ObjectName + allRecords := make([][]common.ReadResultRow, len(uuids)) + jobs := make([]simultaneously.Job, len(uuids)) + + for i, empUUID := range uuids { + idx, uuid := i, empUUID + + jobs[idx] = func(ctx context.Context) error { + rows, fetchErr := c.fetchEmployeeChildren(ctx, uuid, childSuffix, params) + if fetchErr != nil { + return fmt.Errorf("fetching %s for employee %s: %w", childSuffix, uuid, fetchErr) + } + + allRecords[idx] = rows + + return nil + } + } + + if err := simultaneously.DoCtx(ctx, maxConcurrentEmployeeFetch, jobs...); err != nil { + return nil, err + } + + var data []common.ReadResultRow + for _, rows := range allRecords { + data = append(data, rows...) + } + + return data, nil +} + +// fetchEmployeeChildren calls /v1/employees/{uuid}/{childSuffix} and returns all +// records as ReadResultRows. No child-level pagination is applied — a single +// request with per=100 matches the established pattern for nested reads in this +// repo (Pipedrive, Granola, NetSuite, Gmail). +func (c *Connector) fetchEmployeeChildren( + ctx context.Context, + employeeUUID, childSuffix string, + params common.ReadParams, +) ([]common.ReadResultRow, error) { + apiURL, err := urlbuilder.New(c.ProviderInfo().BaseURL, "v1", "employees", employeeUUID, childSuffix) + if err != nil { + return nil, err + } + + apiURL.WithQueryParam(perParam, defaultPageSize) + apiURL.WithQueryParam(pageParam, "1") + + resp, err := c.JSONHTTPClient().Get(ctx, apiURL.String()) + if err != nil { + return nil, err + } + + result, err := common.ParseResultFiltered( + params, resp, + c.recordsFunc(params.ObjectName), + readhelper.MakeIdentityFilterFunc(nextPageFromPageCounter(nil)), + common.MakeMarshaledDataFunc(nil), + params.Fields, + ) + if err != nil { + return nil, err + } + + return result.Data, nil +} + +// nextPageFromPageCounter increments the page query param when the current page +// was full (records >= per). Returns "" when the page is partial (no more data). +func nextPageFromPageCounter(previousRequestURL *url.URL) common.NextPageFunc { + return func(root *ajson.Node) (string, error) { + if previousRequestURL == nil || root == nil || !root.IsArray() { + return "", nil + } + + records, err := root.GetArray() + if err != nil { + return "", err + } + + per := queryParamIntOrDefault(previousRequestURL, perParam, defaultPageSizeInt()) + if len(records) < per { + return "", nil + } + + currentPage := queryParamIntOrDefault(previousRequestURL, pageParam, 1) + + next, err := cloneURL(previousRequestURL) + if err != nil { + return "", err + } + + next.WithQueryParam(pageParam, strconv.Itoa(currentPage+1)) + + return next.String(), nil + } +} + +// queryParamIntOrDefault returns the int value of the given query param, +// falling back to defaultValue if the param is missing, unparsable, or <= 0. +func queryParamIntOrDefault(u *url.URL, key string, defaultValue int) int { + v, err := strconv.Atoi(u.Query().Get(key)) + if err != nil || v <= 0 { + return defaultValue + } + + return v +} + +// defaultPageSizeInt returns the defaultPageSize constant as an int. +func defaultPageSizeInt() int { + n, _ := strconv.Atoi(defaultPageSize) + + return n +} + +// cloneURL returns a deep-copy urlbuilder.URL of u so the original is not +// mutated when query params are modified. +func cloneURL(u *url.URL) (*urlbuilder.URL, error) { + cloned, err := url.Parse(u.String()) + if err != nil { + return nil, err + } + + return urlbuilder.FromRawURL(cloned) +} diff --git a/providers/gusto/read_test.go b/providers/gusto/read_test.go new file mode 100644 index 000000000..356368729 --- /dev/null +++ b/providers/gusto/read_test.go @@ -0,0 +1,346 @@ +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/read/employees-first-page.json +var employeesFirstPageResponse []byte + +//go:embed test/read/employees-last-page.json +var employeesLastPageResponse []byte + +//go:embed test/read/companies.json +var companiesResponse []byte + +//go:embed test/read/jobs-emp-001.json +var jobsEmp001Response []byte + +//go:embed test/read/jobs-emp-002.json +var jobsEmp002Response []byte + +const testCompanyID = "test-company-uuid" + +func TestRead(t *testing.T) { //nolint:funlen + t.Parallel() + + tests := []testroutines.Read{ + { + Name: "Read object must be included", + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrMissingObjects}, + }, + { + Name: "Object must be supported", + Input: common.ReadParams{ + ObjectName: "compensations", + Fields: connectors.Fields("id"), + }, + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrOperationNotSupportedForObject}, + }, + { + Name: "Company-scoped reads require companyId metadata", + Input: common.ReadParams{ + ObjectName: "employees", + Fields: connectors.Fields("uuid"), + }, + Server: mockserver.Dummy(), + ExpectedErrs: []error{ErrMissingCompanyID}, + }, + { + Name: "Employee-scoped reads require companyId metadata", + Input: common.ReadParams{ + ObjectName: "jobs", + Fields: connectors.Fields("uuid"), + }, + Server: mockserver.Dummy(), + ExpectedErrs: []error{ErrMissingCompanyID}, + }, + { + Name: "Read employees with full page returns next page", + Input: common.ReadParams{ + ObjectName: "employees", + Fields: connectors.Fields("uuid", "first_name", "last_name"), + PageSize: 2, + }, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodGET(), + mockcond.Path("/v1/companies/" + testCompanyID + "/employees"), + mockcond.QueryParam("per", "2"), + mockcond.QueryParam("page", "1"), + }, + Then: mockserver.Response(http.StatusOK, employeesFirstPageResponse), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected request"}`), + }.Server(), + Comparator: testroutines.ComparatorSubsetRead, + Expected: &common.ReadResult{ + Rows: 2, + Data: []common.ReadResultRow{{ + Fields: map[string]any{ + "uuid": "emp_001", + "first_name": "Alice", + "last_name": "Anderson", + }, + // Raw must include "email" even though it wasn't requested in Fields, + // proving the raw response is preserved as-is. + Raw: map[string]any{ + "uuid": "emp_001", + "first_name": "Alice", + "last_name": "Anderson", + "email": "alice@example.com", + }, + }, { + Fields: map[string]any{ + "uuid": "emp_002", + "first_name": "Bob", + "last_name": "Brown", + }, + Raw: map[string]any{ + "uuid": "emp_002", + "first_name": "Bob", + "last_name": "Brown", + "email": "bob@example.com", + }, + }}, + NextPage: testroutines.URLTestServer + "/v1/companies/" + testCompanyID + "/employees?page=2&per=2", + Done: false, + }, + }, + { + Name: "Read employees partial page signals Done", + Input: common.ReadParams{ + ObjectName: "employees", + Fields: connectors.Fields("uuid"), + PageSize: 2, + NextPage: testroutines.URLTestServer + "/v1/companies/" + testCompanyID + "/employees?page=2&per=2", + }, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodGET(), + mockcond.Path("/v1/companies/" + testCompanyID + "/employees"), + mockcond.QueryParam("per", "2"), + mockcond.QueryParam("page", "2"), + }, + Then: mockserver.Response(http.StatusOK, employeesLastPageResponse), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected request"}`), + }.Server(), + Comparator: testroutines.ComparatorPagination, + Expected: &common.ReadResult{ + Rows: 1, + NextPage: "", + Done: true, + }, + }, + { + Name: "Read companies does not require companyId substitution", + Input: common.ReadParams{ + ObjectName: "companies", + Fields: connectors.Fields("uuid", "name"), + }, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodGET(), + mockcond.Path("/v1/companies"), + }, + Then: mockserver.Response(http.StatusOK, companiesResponse), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected request"}`), + }.Server(), + Comparator: testroutines.ComparatorSubsetRead, + Expected: &common.ReadResult{ + Rows: 1, + Data: []common.ReadResultRow{{ + Fields: map[string]any{ + "uuid": "comp_001", + "name": "Ampersand Test Co", + }, + Raw: map[string]any{ + "uuid": "comp_001", + "name": "Ampersand Test Co", + "ein": "12-3456789", + }, + }}, + NextPage: "", + Done: true, + }, + }, + { + Name: "Empty response returns Done", + Input: common.ReadParams{ + ObjectName: "employees", + Fields: connectors.Fields("uuid"), + }, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.MethodGET(), + Then: mockserver.Response(http.StatusOK, []byte(`[]`)), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected request"}`), + }.Server(), + Comparator: testroutines.ComparatorPagination, + Expected: &common.ReadResult{ + Rows: 0, + NextPage: "", + Done: true, + }, + }, + { + Name: "Read jobs fans out per employee and returns flattened records", + Input: common.ReadParams{ + ObjectName: "jobs", + Fields: connectors.Fields("uuid", "title", "employee_uuid"), + PageSize: 2, + }, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodGET(), + mockcond.Path("/v1/companies/" + testCompanyID + "/employees"), + mockcond.QueryParam("per", "2"), + mockcond.QueryParam("page", "1"), + }, + Then: mockserver.Response(http.StatusOK, employeesFirstPageResponse), + }, + { + If: mockcond.And{ + mockcond.MethodGET(), + mockcond.Path("/v1/employees/emp_001/jobs"), + }, + Then: mockserver.Response(http.StatusOK, jobsEmp001Response), + }, + { + If: mockcond.And{ + mockcond.MethodGET(), + mockcond.Path("/v1/employees/emp_002/jobs"), + }, + Then: mockserver.Response(http.StatusOK, jobsEmp002Response), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected request"}`), + }.Server(), + Comparator: testroutines.ComparatorSubsetRead, + Expected: &common.ReadResult{ + // 2 jobs from emp_001 + 1 job from emp_002 = 3 total + Rows: 3, + Data: []common.ReadResultRow{{ + Fields: map[string]any{ + "uuid": "job_001", + "title": "Software Engineer", + "employee_uuid": "emp_001", + }, + // Raw must include "rate", "payment_unit", "primary" even though + // they weren't requested — proves the child-fetch response is + // preserved intact through the fan-out. + Raw: map[string]any{ + "uuid": "job_001", + "employee_uuid": "emp_001", + "title": "Software Engineer", + "rate": "80000.00", + "payment_unit": "Year", + "primary": true, + }, + }}, + // Employee list had exactly 2 rows (== per=2), so there may be more employees. + NextPage: testroutines.URLTestServer + "/v1/companies/" + testCompanyID + "/employees?page=2&per=2", + Done: false, + }, + }, + { + Name: "Read jobs with empty employee list returns Done", + Input: common.ReadParams{ + ObjectName: "jobs", + Fields: connectors.Fields("uuid"), + }, + Server: mockserver.Switch{ + Setup: mockserver.ContentJSON(), + Cases: []mockserver.Case{ + { + If: mockcond.And{ + mockcond.MethodGET(), + mockcond.Path("/v1/companies/" + testCompanyID + "/employees"), + }, + Then: mockserver.Response(http.StatusOK, []byte(`[]`)), + }, + }, + Default: mockserver.ResponseString(http.StatusInternalServerError, `{"error":"unexpected request"}`), + }.Server(), + Comparator: testroutines.ComparatorPagination, + Expected: &common.ReadResult{ + Rows: 0, + NextPage: "", + Done: true, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + + tt.Run(t, func() (connectors.ReadConnector, error) { + noCompanyIDCases := map[string]bool{ + "Company-scoped reads require companyId metadata": true, + "Employee-scoped reads require companyId metadata": true, + } + + if noCompanyIDCases[tt.Name] { + return constructTestReadConnector(tt.Server.URL, "") + } + + return constructTestReadConnector(tt.Server.URL, testCompanyID) + }) + }) + } +} + +func constructTestReadConnector(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/providers/gusto/test/read/companies.json b/providers/gusto/test/read/companies.json new file mode 100644 index 000000000..75d4b6877 --- /dev/null +++ b/providers/gusto/test/read/companies.json @@ -0,0 +1,7 @@ +[ + { + "uuid": "comp_001", + "name": "Ampersand Test Co", + "ein": "12-3456789" + } +] diff --git a/providers/gusto/test/read/employees-first-page.json b/providers/gusto/test/read/employees-first-page.json new file mode 100644 index 000000000..b0ce85487 --- /dev/null +++ b/providers/gusto/test/read/employees-first-page.json @@ -0,0 +1,14 @@ +[ + { + "uuid": "emp_001", + "first_name": "Alice", + "last_name": "Anderson", + "email": "alice@example.com" + }, + { + "uuid": "emp_002", + "first_name": "Bob", + "last_name": "Brown", + "email": "bob@example.com" + } +] diff --git a/providers/gusto/test/read/employees-last-page.json b/providers/gusto/test/read/employees-last-page.json new file mode 100644 index 000000000..f7f8d6a9d --- /dev/null +++ b/providers/gusto/test/read/employees-last-page.json @@ -0,0 +1,8 @@ +[ + { + "uuid": "emp_003", + "first_name": "Carol", + "last_name": "Clarke", + "email": "carol@example.com" + } +] diff --git a/providers/gusto/test/read/jobs-emp-001.json b/providers/gusto/test/read/jobs-emp-001.json new file mode 100644 index 000000000..424218dba --- /dev/null +++ b/providers/gusto/test/read/jobs-emp-001.json @@ -0,0 +1,18 @@ +[ + { + "uuid": "job_001", + "employee_uuid": "emp_001", + "title": "Software Engineer", + "rate": "80000.00", + "payment_unit": "Year", + "primary": true + }, + { + "uuid": "job_002", + "employee_uuid": "emp_001", + "title": "Tech Lead", + "rate": "90000.00", + "payment_unit": "Year", + "primary": false + } +] diff --git a/providers/gusto/test/read/jobs-emp-002.json b/providers/gusto/test/read/jobs-emp-002.json new file mode 100644 index 000000000..ada1260c7 --- /dev/null +++ b/providers/gusto/test/read/jobs-emp-002.json @@ -0,0 +1,10 @@ +[ + { + "uuid": "job_003", + "employee_uuid": "emp_002", + "title": "Product Manager", + "rate": "95000.00", + "payment_unit": "Year", + "primary": true + } +] diff --git a/test/gusto/connector.go b/test/gusto/connector.go index b62357626..2a9202033 100644 --- a/test/gusto/connector.go +++ b/test/gusto/connector.go @@ -11,13 +11,23 @@ import ( "golang.org/x/oauth2" ) +//nolint:gochecknoglobals +var fieldCompanyID = credscanning.Field{ + Name: "companyId", + PathJSON: "metadata.companyId", + SuffixENV: "COMPANY_ID", +} + func GetConnector(ctx context.Context) *gusto.Connector { - filePath := credscanning.LoadPath(providers.Gusto) - reader := utils.MustCreateProvCredJSON(filePath, true) + filePath := credscanning.LoadPath(providers.GustoDemo) + reader := utils.MustCreateProvCredJSON(filePath, true, fieldCompanyID) - conn, err := gusto.NewConnector( + conn, err := gusto.NewDemoConnector( common.ConnectorParams{ AuthenticatedClient: utils.NewOauth2Client(ctx, reader, getConfig), + Metadata: map[string]string{ + "companyId": reader.Get(fieldCompanyID), + }, }, ) if err != nil { @@ -33,8 +43,8 @@ func getConfig(reader *credscanning.ProviderCredentials) *oauth2.Config { ClientSecret: reader.Get(credscanning.Fields.ClientSecret), RedirectURL: "http://localhost:8080/callbacks/v1/oauth", Endpoint: oauth2.Endpoint{ - AuthURL: "https://api.gusto.com/oauth/authorize", - TokenURL: "https://api.gusto.com/oauth/token", + AuthURL: "https://api.gusto-demo.com/oauth/authorize", + TokenURL: "https://api.gusto-demo.com/oauth/token", AuthStyle: oauth2.AuthStyleAutoDetect, }, } diff --git a/test/gusto/read/main.go b/test/gusto/read/main.go new file mode 100644 index 000000000..737bc2979 --- /dev/null +++ b/test/gusto/read/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "log/slog" + "os/signal" + "syscall" + + "github.com/amp-labs/connectors" + "github.com/amp-labs/connectors/common" + connTest "github.com/amp-labs/connectors/test/gusto" + "github.com/amp-labs/connectors/test/utils" + "github.com/amp-labs/connectors/test/utils/testscenario" +) + +func main() { + ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer done() + + utils.SetupLogging() + + conn := connTest.GetConnector(ctx) + + slog.Info("=== Reading employees ===") + testscenario.ReadThroughPages(ctx, conn, common.ReadParams{ + ObjectName: "employees", + Fields: connectors.Fields("uuid", "first_name", "last_name", "email"), + }) + + slog.Info("=== Reading locations ===") + testscenario.ReadThroughPages(ctx, conn, common.ReadParams{ + ObjectName: "locations", + Fields: connectors.Fields("uuid", "street_1", "city", "state"), + }) + + slog.Info("=== Reading departments ===") + testscenario.ReadThroughPages(ctx, conn, common.ReadParams{ + ObjectName: "departments", + Fields: connectors.Fields("uuid", "title"), + }) + + slog.Info("=== Reading pay_schedules ===") + testscenario.ReadThroughPages(ctx, conn, common.ReadParams{ + ObjectName: "pay_schedules", + Fields: connectors.Fields("uuid", "frequency"), + }) + + slog.Info("=== Reading contractors ===") + testscenario.ReadThroughPages(ctx, conn, common.ReadParams{ + ObjectName: "contractors", + Fields: connectors.Fields("uuid", "first_name", "last_name"), + }) + + slog.Info("=== Reading jobs (employee-scoped) ===") + testscenario.ReadThroughPages(ctx, conn, common.ReadParams{ + ObjectName: "jobs", + Fields: connectors.Fields("uuid", "title", "employee_uuid", "rate", "payment_unit"), + }) + + slog.Info("=== Reading garnishments (employee-scoped) ===") + testscenario.ReadThroughPages(ctx, conn, common.ReadParams{ + ObjectName: "garnishments", + Fields: connectors.Fields("uuid", "employee_uuid", "description", "amount"), + }) + + slog.Info("=== Reading employee_benefits (employee-scoped) ===") + testscenario.ReadThroughPages(ctx, conn, common.ReadParams{ + ObjectName: "employee_benefits", + Fields: connectors.Fields("uuid", "employee_uuid", "company_benefit_uuid", "active"), + }) + + slog.Info("=== Reading home_addresses (employee-scoped) ===") + testscenario.ReadThroughPages(ctx, conn, common.ReadParams{ + ObjectName: "home_addresses", + Fields: connectors.Fields("uuid", "employee_uuid", "city", "state"), + }) +}