From e2d6972da5c0c9680ea8ed788b6bc58cca531f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 10 Jun 2026 15:32:55 +0200 Subject: [PATCH] Add importThirdPartyFromCommon GraphQL mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the explicit import action over the console API. The mutation takes an organization and a common third party, authorizes as a third-party create, and delegates to ThirdPartyService.ImportFromCommon, returning the org ThirdParty edge plus a created flag so the client can tell a fresh import from a re-import. Add an end-to-end test covering the two behaviours that matter: the first import seeds the org vendor from the catalog and backfills the linked tracker pattern's third_party_id, and a second import is idempotent, returning the same row with created=false. The gqlgen-generated types and execution code are build artifacts (not tracked), so only the schema and the resolver change here. Signed-off-by: Émile Ré --- e2e/console/third_party_import_test.go | 144 ++++++++++++++++++ .../console/v1/graphql/third_party.graphql | 13 ++ .../api/console/v1/third_party_resolvers.go | 30 ++++ 3 files changed, 187 insertions(+) create mode 100644 e2e/console/third_party_import_test.go diff --git a/e2e/console/third_party_import_test.go b/e2e/console/third_party_import_test.go new file mode 100644 index 000000000..6a183aea5 --- /dev/null +++ b/e2e/console/third_party_import_test.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" + "go.probo.inc/probo/pkg/gid" +) + +func TestThirdParty_ImportFromCommon(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + // Catalog: a common third party and a common tracker pattern linked + // to it. + commonName := factory.SafeName("ImportTP") + commonThirdPartyID := seedCommonThirdParty(t, commonName) + commonPatternID := seedCommonTrackerPattern(t) + linkCommonTrackerPatternToVendor(t, commonPatternID, commonThirdPartyID) + + // An org tracker pattern linked to that catalog row but with no org + // third party yet (the state the mapping worker now leaves behind). + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + patternID := factory.CreateTrackerPattern(owner, categoryID) + linkTrackerPatternToCommon(t, patternID, commonPatternID) + + const mutation = ` + mutation($input: ImportThirdPartyFromCommonInput!) { + importThirdPartyFromCommon(input: $input) { + created + thirdPartyEdge { + node { + id + name + } + } + } + } + ` + + input := map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "commonThirdPartyId": commonThirdPartyID.String(), + } + + var first struct { + ImportThirdPartyFromCommon struct { + Created bool `json:"created"` + ThirdPartyEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"thirdPartyEdge"` + } `json:"importThirdPartyFromCommon"` + } + + require.NoError(t, owner.Execute(mutation, map[string]any{"input": input}, &first)) + assert.True(t, first.ImportThirdPartyFromCommon.Created, "first import must create the org third party") + + importedID := first.ImportThirdPartyFromCommon.ThirdPartyEdge.Node.ID + require.NotEmpty(t, importedID) + assert.Equal(t, commonName, first.ImportThirdPartyFromCommon.ThirdPartyEdge.Node.Name, "the org third party is seeded from the catalog name") + + // The linked tracker pattern is backfilled to the imported org vendor. + const patternQuery = ` + query($id: ID!) { + node(id: $id) { + ... on TrackerPattern { + thirdParty { + id + } + } + } + } + ` + + var patternResult struct { + Node struct { + ThirdParty *struct { + ID string `json:"id"` + } `json:"thirdParty"` + } `json:"node"` + } + + require.NoError(t, owner.Execute(patternQuery, map[string]any{"id": patternID}, &patternResult)) + require.NotNil(t, patternResult.Node.ThirdParty, "the tracker pattern must be linked to the imported org third party") + assert.Equal(t, importedID, patternResult.Node.ThirdParty.ID) + + // Re-importing the same catalog vendor is idempotent: it returns the + // same row and reports that nothing was created. + var second struct { + ImportThirdPartyFromCommon struct { + Created bool `json:"created"` + ThirdPartyEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"thirdPartyEdge"` + } `json:"importThirdPartyFromCommon"` + } + + require.NoError(t, owner.Execute(mutation, map[string]any{"input": input}, &second)) + assert.False(t, second.ImportThirdPartyFromCommon.Created, "re-import must not create a duplicate") + assert.Equal(t, importedID, second.ImportThirdPartyFromCommon.ThirdPartyEdge.Node.ID, "re-import must return the existing org third party") +} + +// linkCommonTrackerPatternToVendor attaches a catalog tracker pattern to +// a common third party, the link the import action follows to resolve +// which org vendor a pattern belongs to. +func linkCommonTrackerPatternToVendor(t *testing.T, commonPatternID gid.GID, commonThirdPartyID gid.GID) { + t.Helper() + + ctx := context.Background() + conn := dialTestPg(t, ctx) + t.Cleanup(func() { _ = conn.Close(ctx) }) + + _, err := conn.Exec( + ctx, + `UPDATE common_tracker_patterns SET common_third_party_id = $1 WHERE id = $2`, + commonThirdPartyID, + commonPatternID, + ) + require.NoError(t, err) +} diff --git a/pkg/server/api/console/v1/graphql/third_party.graphql b/pkg/server/api/console/v1/graphql/third_party.graphql index 971b87a9e..057241bc4 100644 --- a/pkg/server/api/console/v1/graphql/third_party.graphql +++ b/pkg/server/api/console/v1/graphql/third_party.graphql @@ -454,6 +454,9 @@ type ThirdPartyRiskAssessmentEdge { extend type Mutation { createThirdParty(input: CreateThirdPartyInput!): CreateThirdPartyPayload! + importThirdPartyFromCommon( + input: ImportThirdPartyFromCommonInput! + ): ImportThirdPartyFromCommonPayload! updateThirdParty(input: UpdateThirdPartyInput!): UpdateThirdPartyPayload! deleteThirdParty(input: DeleteThirdPartyInput!): DeleteThirdPartyPayload! createThirdPartyContact( @@ -672,10 +675,20 @@ input VetThirdPartyInput { procedure: String } +input ImportThirdPartyFromCommonInput { + organizationId: ID! + commonThirdPartyId: ID! +} + type CreateThirdPartyPayload { thirdPartyEdge: ThirdPartyEdge! } +type ImportThirdPartyFromCommonPayload { + thirdPartyEdge: ThirdPartyEdge! + created: Boolean! +} + type UpdateThirdPartyPayload { thirdParty: ThirdParty! } diff --git a/pkg/server/api/console/v1/third_party_resolvers.go b/pkg/server/api/console/v1/third_party_resolvers.go index 1cd22d1a6..ec15790c0 100644 --- a/pkg/server/api/console/v1/third_party_resolvers.go +++ b/pkg/server/api/console/v1/third_party_resolvers.go @@ -83,6 +83,36 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre }, nil } +// ImportThirdPartyFromCommon is the resolver for the importThirdPartyFromCommon field. +func (r *mutationResolver) ImportThirdPartyFromCommon(ctx context.Context, input types.ImportThirdPartyFromCommonInput) (*types.ImportThirdPartyFromCommonPayload, error) { + scope, err := r.authorize(ctx, input.OrganizationID, probo.ActionThirdPartyCreate) + if err != nil { + return nil, err + } + + thirdParty, created, err := r.probo.ThirdParties.ImportFromCommon( + ctx, scope, + probo.ImportThirdPartyFromCommonRequest{ + OrganizationID: input.OrganizationID, + CommonThirdPartyID: input.CommonThirdPartyID, + }, + ) + if err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + + r.logger.ErrorCtx(ctx, "cannot import thirdParty from common", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.ImportThirdPartyFromCommonPayload{ + ThirdPartyEdge: types.NewThirdPartyEdge(thirdParty, coredata.ThirdPartyOrderFieldName), + Created: created, + }, nil +} + // UpdateThirdParty is the resolver for the updateThirdParty field. func (r *mutationResolver) UpdateThirdParty(ctx context.Context, input types.UpdateThirdPartyInput) (*types.UpdateThirdPartyPayload, error) { scope, err := r.authorize(ctx, input.ID, probo.ActionThirdPartyUpdate)