diff --git a/controlplane/src/core/bufservices/subgraph/deleteFederatedSubgraph.ts b/controlplane/src/core/bufservices/subgraph/deleteFederatedSubgraph.ts index eea4270cb6..e32b6a2a02 100644 --- a/controlplane/src/core/bufservices/subgraph/deleteFederatedSubgraph.ts +++ b/controlplane/src/core/bufservices/subgraph/deleteFederatedSubgraph.ts @@ -89,12 +89,10 @@ export function deleteFederatedSubgraph( }); let proposalMatchMessage: string | undefined; - let matchedEntity: - | { - proposalId: string; - proposalSubgraphId: string; - } - | undefined; + const matchedProposalEntities: { + proposalId: string; + proposalSubgraphId: string; + }[] = []; if (namespace.enableProposals) { const federatedGraphs = await fedGraphRepo.bySubgraphLabels({ labels: subgraph.labels, @@ -102,14 +100,14 @@ export function deleteFederatedSubgraph( }); const proposalConfig = await proposalRepo.getProposalConfig({ namespaceId: namespace.id }); if (proposalConfig) { - const match = await proposalRepo.matchSchemaWithProposal({ + const matches = await proposalRepo.matchSchemaWithProposals({ subgraphName: subgraph.name, namespaceId: namespace.id, schemaSDL: '', routerCompatibilityVersion: getFederatedGraphRouterCompatibilityVersion(federatedGraphs), isDeleted: true, }); - if (!match) { + if (matches.length === 0) { if (proposalConfig.publishSeverityLevel === 'warn') { proposalMatchMessage = `The subgraph ${subgraph.name} is not proposed to be deleted in any of the approved proposals.`; } else { @@ -125,7 +123,7 @@ export function deleteFederatedSubgraph( }; } } - matchedEntity = match; + matchedProposalEntities.push(...matches); } } @@ -188,9 +186,45 @@ export function deleteFederatedSubgraph( federatedGraphs: affectedFederatedGraphs, }); + // Re-fetch the federated graphs to get the updated composedSchemaVersionId + const refreshedGraphs = await Promise.all(affectedFederatedGraphs.map((g) => fedGraphRepo.byId(g.id))); + for (let i = 0; i < affectedFederatedGraphs.length; i++) { + const refreshedGraph = refreshedGraphs[i]; + if (refreshedGraph) { + affectedFederatedGraphs[i] = refreshedGraph; + } + } + return { affectedFederatedGraphs, compositionErrors, deploymentErrors, compositionWarnings }; }); + // if this subgraph is part of a proposal, mark the proposal subgraph as published + // and if all proposal subgraphs are published, collect proposal details for the webhook + const proposalDetailsList: { + id: string; + name: string; + namespace: string; + federatedGraphId: string; + }[] = []; + + for (const matchedEntity of matchedProposalEntities) { + const { allSubgraphsPublished } = await proposalRepo.markProposalSubgraphAsPublished({ + proposalSubgraphId: matchedEntity.proposalSubgraphId, + proposalId: matchedEntity.proposalId, + }); + if (allSubgraphsPublished) { + const proposal = await proposalRepo.ById(matchedEntity.proposalId); + if (proposal) { + proposalDetailsList.push({ + id: proposal.proposal.id, + name: proposal.proposal.name, + namespace: req.namespace, + federatedGraphId: proposal.proposal.federatedGraphId, + }); + } + } + } + for (const affectedFederatedGraph of affectedFederatedGraphs) { const hasErrors = compositionErrors.some((error) => error.federatedGraphName === affectedFederatedGraph.name) || @@ -203,6 +237,7 @@ export function deleteFederatedSubgraph( id: affectedFederatedGraph.id, name: affectedFederatedGraph.name, namespace: affectedFederatedGraph.namespace, + composedSchemaVersionId: affectedFederatedGraph.composedSchemaVersionId, }, organization: { id: authContext.organizationId, @@ -216,44 +251,34 @@ export function deleteFederatedSubgraph( ); } - // if this subgraph is part of a proposal, mark the proposal subgraph as published - // and if all proposal subgraphs are published, update the proposal state to PUBLISHED - if (matchedEntity) { - const { allSubgraphsPublished } = await proposalRepo.markProposalSubgraphAsPublished({ - proposalSubgraphId: matchedEntity.proposalSubgraphId, - proposalId: matchedEntity.proposalId, - }); - if (allSubgraphsPublished) { - const proposal = await proposalRepo.ById(matchedEntity.proposalId); - if (proposal) { - const federatedGraph = await fedGraphRepo.byId(proposal.proposal.federatedGraphId); - if (federatedGraph) { - orgWebhooks.send( - { - eventName: OrganizationEventName.PROPOSAL_STATE_UPDATED, - payload: { - federated_graph: { - id: federatedGraph.id, - name: federatedGraph.name, - namespace: federatedGraph.namespace, - }, - organization: { - id: authContext.organizationId, - slug: authContext.organizationSlug, - }, - proposal: { - id: proposal.proposal.id, - name: proposal.proposal.name, - namespace: req.namespace, - state: 'PUBLISHED', - }, - actor_id: authContext.userId, - }, + // Send PROPOSAL_STATE_UPDATED webhook for each published proposal + for (const proposalDetails of proposalDetailsList) { + const federatedGraph = affectedFederatedGraphs.find((g) => g.id === proposalDetails.federatedGraphId); + if (federatedGraph) { + orgWebhooks.send( + { + eventName: OrganizationEventName.PROPOSAL_STATE_UPDATED, + payload: { + federated_graph: { + id: federatedGraph.id, + name: federatedGraph.name, + namespace: federatedGraph.namespace, }, - authContext.userId, - ); - } - } + organization: { + id: authContext.organizationId, + slug: authContext.organizationSlug, + }, + proposal: { + id: proposalDetails.id, + name: proposalDetails.name, + namespace: proposalDetails.namespace, + state: 'PUBLISHED', + }, + actor_id: authContext.userId, + }, + }, + authContext.userId, + ); } } diff --git a/controlplane/src/core/bufservices/subgraph/publishFederatedSubgraph.ts b/controlplane/src/core/bufservices/subgraph/publishFederatedSubgraph.ts index 88c6eb9fb5..26e31eac0e 100644 --- a/controlplane/src/core/bufservices/subgraph/publishFederatedSubgraph.ts +++ b/controlplane/src/core/bufservices/subgraph/publishFederatedSubgraph.ts @@ -125,25 +125,23 @@ export function publishFederatedSubgraph( } let proposalMatchMessage: string | undefined; - let matchedEntity: - | { - proposalId: string; - proposalSubgraphId: string; - } - | undefined; + const matchedProposalEntities: { + proposalId: string; + proposalSubgraphId: string; + }[] = []; // if the subgraph is a feature subgraph, we don't need to check for proposal matches for now. if (namespace.enableProposals && !subgraph?.isFeatureSubgraph) { const proposalConfig = await proposalRepo.getProposalConfig({ namespaceId: namespace.id }); if (proposalConfig) { - const match = await proposalRepo.matchSchemaWithProposal({ + const matches = await proposalRepo.matchSchemaWithProposals({ subgraphName: req.name, namespaceId: namespace.id, schemaSDL: subgraphSchemaSDL, routerCompatibilityVersion, isDeleted: false, }); - if (!match) { + if (matches.length === 0) { const message = `The subgraph ${req.name}'s schema does not match to this subgraph's schema in any approved proposal.`; if (proposalConfig.publishSeverityLevel === 'warn') { proposalMatchMessage = message; @@ -160,7 +158,7 @@ export function publishFederatedSubgraph( }; } } - matchedEntity = match; + matchedProposalEntities.push(...matches); } } @@ -577,6 +575,33 @@ export function publishFederatedSubgraph( newCompositionOptions(req.disableResolvabilityValidation), ); + // if this subgraph is part of a proposal, mark the proposal subgraph as published + // and if all proposal subgraphs are published, collect proposal details for the webhook + const proposalDetailsList: { + id: string; + name: string; + namespace: string; + federatedGraphId: string; + }[] = []; + + for (const matchedEntity of matchedProposalEntities) { + const { allSubgraphsPublished } = await proposalRepo.markProposalSubgraphAsPublished({ + proposalSubgraphId: matchedEntity.proposalSubgraphId, + proposalId: matchedEntity.proposalId, + }); + if (allSubgraphsPublished) { + const proposal = await proposalRepo.ById(matchedEntity.proposalId); + if (proposal) { + proposalDetailsList.push({ + id: proposal.proposal.id, + name: proposal.proposal.name, + namespace: req.namespace, + federatedGraphId: proposal.proposal.federatedGraphId, + }); + } + } + } + for (const graph of updatedFederatedGraphs) { const hasErrors = compositionErrors.some((error) => error.federatedGraphName === graph.name) || @@ -589,6 +614,7 @@ export function publishFederatedSubgraph( id: graph.id, name: graph.name, namespace: graph.namespace, + composedSchemaVersionId: graph.composedSchemaVersionId, }, organization: { id: authContext.organizationId, @@ -602,44 +628,34 @@ export function publishFederatedSubgraph( ); } - // if this subgraph is part of a proposal, mark the proposal subgraph as published - // and if all proposal subgraphs are published, update the proposal state to PUBLISHED - if (matchedEntity) { - const { allSubgraphsPublished } = await proposalRepo.markProposalSubgraphAsPublished({ - proposalSubgraphId: matchedEntity.proposalSubgraphId, - proposalId: matchedEntity.proposalId, - }); - if (allSubgraphsPublished) { - const proposal = await proposalRepo.ById(matchedEntity.proposalId); - if (proposal) { - const federatedGraph = await fedGraphRepo.byId(proposal.proposal.federatedGraphId); - if (federatedGraph) { - orgWebhooks.send( - { - eventName: OrganizationEventName.PROPOSAL_STATE_UPDATED, - payload: { - federated_graph: { - id: federatedGraph.id, - name: federatedGraph.name, - namespace: federatedGraph.namespace, - }, - organization: { - id: authContext.organizationId, - slug: authContext.organizationSlug, - }, - proposal: { - id: proposal.proposal.id, - name: proposal.proposal.name, - namespace: req.namespace, - state: 'PUBLISHED', - }, - actor_id: authContext.userId, - }, + // Send PROPOSAL_STATE_UPDATED webhook for each published proposal + for (const proposalDetails of proposalDetailsList) { + const federatedGraph = updatedFederatedGraphs.find((g) => g.id === proposalDetails.federatedGraphId); + if (federatedGraph) { + orgWebhooks.send( + { + eventName: OrganizationEventName.PROPOSAL_STATE_UPDATED, + payload: { + federated_graph: { + id: federatedGraph.id, + name: federatedGraph.name, + namespace: federatedGraph.namespace, }, - authContext.userId, - ); - } - } + organization: { + id: authContext.organizationId, + slug: authContext.organizationSlug, + }, + proposal: { + id: proposalDetails.id, + name: proposalDetails.name, + namespace: proposalDetails.namespace, + state: 'PUBLISHED', + }, + actor_id: authContext.userId, + }, + }, + authContext.userId, + ); } } diff --git a/controlplane/src/core/repositories/ProposalRepository.ts b/controlplane/src/core/repositories/ProposalRepository.ts index f5d1b85de7..7b207db5f9 100644 --- a/controlplane/src/core/repositories/ProposalRepository.ts +++ b/controlplane/src/core/repositories/ProposalRepository.ts @@ -480,7 +480,7 @@ export class ProposalRepository { return proposalSubgraphs; } - public async matchSchemaWithProposal({ + public async matchSchemaWithProposals({ subgraphName, namespaceId, schemaCheckId, @@ -494,12 +494,14 @@ export class ProposalRepository { schemaSDL: string; routerCompatibilityVersion: string; isDeleted: boolean; - }): Promise<{ proposalId: string; proposalSubgraphId: string } | undefined> { + }): Promise<{ proposalId: string; proposalSubgraphId: string }[]> { const proposalSubgraphs = await this.getApprovedProposalSubgraphsBySubgraph({ subgraphName, namespaceId, }); + const matches: { proposalId: string; proposalSubgraphId: string }[] = []; + for (const proposalSubgraph of proposalSubgraphs) { if (proposalSubgraph.isDeleted && isDeleted) { if (schemaCheckId) { @@ -517,10 +519,11 @@ export class ProposalRepository { }, }); } - return { + matches.push({ proposalId: proposalSubgraph.proposalId, proposalSubgraphId: proposalSubgraph.id, - }; + }); + continue; } if (!proposalSubgraph.proposedSchemaSDL) { @@ -549,13 +552,13 @@ export class ProposalRepository { } if (schemaChanges.changes.length === 0) { - return { + matches.push({ proposalId: proposalSubgraph.proposalId, proposalSubgraphId: proposalSubgraph.id, - }; + }); } } - return undefined; + return matches; } public async getLatestCheckForProposal( diff --git a/controlplane/src/core/repositories/SchemaCheckRepository.ts b/controlplane/src/core/repositories/SchemaCheckRepository.ts index 0b5ecc57f0..a93a368f3d 100644 --- a/controlplane/src/core/repositories/SchemaCheckRepository.ts +++ b/controlplane/src/core/repositories/SchemaCheckRepository.ts @@ -859,7 +859,7 @@ export class SchemaCheckRepository { const proposalConfig = await proposalRepo.getProposalConfig({ namespaceId: namespace.id }); // currently matching only with the subgraph that is already present in the namespace if (proposalConfig) { - const match = await proposalRepo.matchSchemaWithProposal({ + const matches = await proposalRepo.matchSchemaWithProposals({ subgraphName, namespaceId: namespace.id, schemaSDL: newSchemaSDL, @@ -870,10 +870,11 @@ export class SchemaCheckRepository { await this.update({ schemaCheckID, - proposalMatch: match ? 'success' : proposalConfig.checkSeverityLevel === 'warn' ? 'warn' : 'error', + proposalMatch: + matches.length > 0 ? 'success' : proposalConfig.checkSeverityLevel === 'warn' ? 'warn' : 'error', }); - if (!match) { + if (matches.length === 0) { if (proposalConfig.checkSeverityLevel === 'warn') { proposalMatchMessage += `The subgraph ${subgraphName}'s schema does not match to this subgraph's schema in any approved proposal.\n`; } else { diff --git a/controlplane/src/core/repositories/SubgraphRepository.ts b/controlplane/src/core/repositories/SubgraphRepository.ts index 8f6a1805c0..2e8e52d926 100644 --- a/controlplane/src/core/repositories/SubgraphRepository.ts +++ b/controlplane/src/core/repositories/SubgraphRepository.ts @@ -480,6 +480,15 @@ export class SubgraphRepository { compositionErrors.push(...cErrors); deploymentErrors.push(...dErrors); compositionWarnings.push(...cWarnings); + + // Re-fetch the federated graphs to get the updated composedSchemaVersionId + const refreshedGraphs = await Promise.all(updatedFederatedGraphs.map((g) => fedGraphRepo.byId(g.id))); + for (let i = 0; i < updatedFederatedGraphs.length; i++) { + const refreshedGraph = refreshedGraphs[i]; + if (refreshedGraph) { + updatedFederatedGraphs[i] = refreshedGraph; + } + } }); return { @@ -561,6 +570,15 @@ export class SubgraphRepository { compositionOptions, }); + // Re-fetch the federated graphs to get the updated composedSchemaVersionId + const refreshedGraphs = await Promise.all(updatedFederatedGraphs.map((g) => fedGraphRepo.byId(g.id))); + for (let i = 0; i < updatedFederatedGraphs.length; i++) { + const refreshedGraph = refreshedGraphs[i]; + if (refreshedGraph) { + updatedFederatedGraphs[i] = refreshedGraph; + } + } + return { compositionErrors, updatedFederatedGraphs, deploymentErrors, compositionWarnings }; }); } @@ -1880,7 +1898,7 @@ export class SubgraphRepository { if (namespace.enableProposals && !isTargetCheck) { const proposalConfig = await proposalRepo.getProposalConfig({ namespaceId: namespace.id }); if (proposalConfig) { - const match = await proposalRepo.matchSchemaWithProposal({ + const matches = await proposalRepo.matchSchemaWithProposals({ subgraphName, namespaceId: namespace.id, schemaSDL: newSchemaSDL, @@ -1891,9 +1909,10 @@ export class SubgraphRepository { await schemaCheckRepo.update({ schemaCheckID, - proposalMatch: match ? 'success' : proposalConfig.checkSeverityLevel === 'warn' ? 'warn' : 'error', + proposalMatch: + matches.length > 0 ? 'success' : proposalConfig.checkSeverityLevel === 'warn' ? 'warn' : 'error', }); - if (!match) { + if (matches.length === 0) { const message = isDeleted ? `The subgraph ${subgraphName} is not proposed to be deleted in any of the approved proposals.` : `The subgraph ${subgraphName}'s schema does not match to this subgraph's schema in any approved proposal.`; diff --git a/controlplane/src/core/webhooks/OrganizationWebhookService.ts b/controlplane/src/core/webhooks/OrganizationWebhookService.ts index f4fe1a6748..50448d3748 100644 --- a/controlplane/src/core/webhooks/OrganizationWebhookService.ts +++ b/controlplane/src/core/webhooks/OrganizationWebhookService.ts @@ -56,6 +56,7 @@ export interface FederatedGraphSchemaUpdate { id: string; name: string; namespace: string; + composedSchemaVersionId?: string; }; organization: { id: string; diff --git a/controlplane/test/proposal/proposal-webhooks.test.ts b/controlplane/test/proposal/proposal-webhooks.test.ts new file mode 100644 index 0000000000..b13edc12ec --- /dev/null +++ b/controlplane/test/proposal/proposal-webhooks.test.ts @@ -0,0 +1,770 @@ +import { PartialMessage } from '@bufbuild/protobuf'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { EventMeta, OrganizationEventName } from '@wundergraph/cosmo-connect/dist/notifications/events_pb'; +import { ProposalNamingConvention, ProposalOrigin } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { joinLabel } from '@wundergraph/cosmo-shared'; +import { addMinutes, formatISO, subDays } from 'date-fns'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ClickHouseClient } from '../../src/core/clickhouse/index.js'; +import { afterAllSetup, beforeAllSetup, genID, genUniqueLabel } from '../../src/core/test-util.js'; +import { + createFederatedGraph, + createThenPublishSubgraph, + DEFAULT_NAMESPACE, + DEFAULT_ROUTER_URL, + DEFAULT_SUBGRAPH_URL_ONE, + DEFAULT_SUBGRAPH_URL_TWO, + DEFAULT_SUBGRAPH_URL_THREE, + SetupTest, +} from '../test-util.js'; + +let dbname = ''; + +vi.mock('../../src/core/clickhouse/index.js', () => { + const ClickHouseClient = vi.fn(); + ClickHouseClient.prototype.queryPromise = vi.fn(); + + return { ClickHouseClient }; +}); + +// Helper function to enable proposals for namespace +async function enableProposalsForNamespace(client: any, namespace = DEFAULT_NAMESPACE) { + const enableResponse = await client.enableProposalsForNamespace({ + namespace, + enableProposals: true, + }); + + return enableResponse; +} + +describe('Schema updated webhook tests', () => { + let chClient: ClickHouseClient; + + // Store captured webhook payloads + let capturedWebhooks: Array<{ url: string; payload: any }> = []; + + // Setup msw mock server to capture webhook calls + const mockServer = setupServer( + http.post('http://webhook-fedgraph1.test', async ({ request }) => { + const payload = await request.json(); + capturedWebhooks.push({ url: 'http://webhook-fedgraph1.test', payload }); + return HttpResponse.json({ success: true }); + }), + http.post('http://webhook-fedgraph2.test', async ({ request }) => { + const payload = await request.json(); + capturedWebhooks.push({ url: 'http://webhook-fedgraph2.test', payload }); + return HttpResponse.json({ success: true }); + }), + ); + + beforeEach(() => { + chClient = new ClickHouseClient(); + capturedWebhooks = []; + }); + + afterEach(() => { + vi.clearAllMocks(); + mockServer.resetHandlers(); + }); + + beforeAll(async () => { + mockServer.listen({ onUnhandledRequest: 'bypass' }); + dbname = await beforeAllSetup(); + }); + + afterAll(async () => { + mockServer.close(); + await afterAllSetup(dbname); + }); + + test('should include correct composedSchemaVersionId and send PROPOSAL_STATE_UPDATED webhook when publishing subgraph', async () => { + const { client, server } = await SetupTest({ + dbname, + chClient, + setupBilling: { plan: 'enterprise' }, + enabledFeatures: ['proposals'], + }); + + // Setup: Create two separate federated graphs with different subgraphs + const fedGraph1Name = genID('fedGraph1'); + const fedGraph2Name = genID('fedGraph2'); + const subgraph1Name = genID('subgraph1'); + const subgraph2Name = genID('subgraph2'); + const label1 = genUniqueLabel('label1'); + const label2 = genUniqueLabel('label2'); + const proposal1Name = genID('proposal1'); + const proposal2Name = genID('proposal2'); + + const subgraph1SchemaSDL = ` + type Query { + users: [User!]! + } + + type User { + id: ID! + name: String! + } + `; + + const subgraph2SchemaSDL = ` + type Query { + products: [Product!]! + } + + type Product { + id: ID! + name: String! + } + `; + + // Create first federated graph and subgraph + await createThenPublishSubgraph( + client, + subgraph1Name, + DEFAULT_NAMESPACE, + subgraph1SchemaSDL, + [label1], + DEFAULT_SUBGRAPH_URL_ONE, + ); + + await createFederatedGraph(client, fedGraph1Name, DEFAULT_NAMESPACE, [joinLabel(label1)], DEFAULT_ROUTER_URL); + + // Create second federated graph and subgraph + await createThenPublishSubgraph( + client, + subgraph2Name, + DEFAULT_NAMESPACE, + subgraph2SchemaSDL, + [label2], + DEFAULT_SUBGRAPH_URL_TWO, + ); + + await createFederatedGraph( + client, + fedGraph2Name, + DEFAULT_NAMESPACE, + [joinLabel(label2)], + 'http://localhost:3003', + ); + + // Get federated graph IDs + const fedGraph1Res = await client.getFederatedGraphByName({ + name: fedGraph1Name, + namespace: DEFAULT_NAMESPACE, + }); + expect(fedGraph1Res.response?.code).toBe(EnumStatusCode.OK); + const fedGraph1Id = fedGraph1Res.graph!.id; + + const fedGraph2Res = await client.getFederatedGraphByName({ + name: fedGraph2Name, + namespace: DEFAULT_NAMESPACE, + }); + expect(fedGraph2Res.response?.code).toBe(EnumStatusCode.OK); + const fedGraph2Id = fedGraph2Res.graph!.id; + + // Create webhook configs for each federated graph with different endpoints + const eventsMeta1: PartialMessage[] = [ + { + eventName: OrganizationEventName.FEDERATED_GRAPH_SCHEMA_UPDATED, + meta: { + case: 'federatedGraphSchemaUpdated', + value: { + graphIds: [fedGraph1Id], + }, + }, + }, + { + eventName: OrganizationEventName.PROPOSAL_STATE_UPDATED, + meta: { + case: 'proposalStateUpdated', + value: { + graphIds: [fedGraph1Id], + }, + }, + }, + ]; + + const webhook1Res = await client.createOrganizationWebhookConfig({ + endpoint: 'http://webhook-fedgraph1.test', + events: [ + OrganizationEventName[OrganizationEventName.FEDERATED_GRAPH_SCHEMA_UPDATED], + OrganizationEventName[OrganizationEventName.PROPOSAL_STATE_UPDATED], + ], + eventsMeta: eventsMeta1, + }); + expect(webhook1Res.response?.code).toBe(EnumStatusCode.OK); + + const eventsMeta2: PartialMessage[] = [ + { + eventName: OrganizationEventName.FEDERATED_GRAPH_SCHEMA_UPDATED, + meta: { + case: 'federatedGraphSchemaUpdated', + value: { + graphIds: [fedGraph2Id], + }, + }, + }, + { + eventName: OrganizationEventName.PROPOSAL_STATE_UPDATED, + meta: { + case: 'proposalStateUpdated', + value: { + graphIds: [fedGraph2Id], + }, + }, + }, + ]; + + const webhook2Res = await client.createOrganizationWebhookConfig({ + endpoint: 'http://webhook-fedgraph2.test', + events: [ + OrganizationEventName[OrganizationEventName.FEDERATED_GRAPH_SCHEMA_UPDATED], + OrganizationEventName[OrganizationEventName.PROPOSAL_STATE_UPDATED], + ], + eventsMeta: eventsMeta2, + }); + expect(webhook2Res.response?.code).toBe(EnumStatusCode.OK); + + // Enable proposals for the namespace + const enableResponse = await enableProposalsForNamespace(client); + expect(enableResponse.response?.code).toBe(EnumStatusCode.OK); + + // Create proposal for fedGraph1 + const updatedSubgraph1SDL = ` + type Query { + users: [User!]! + user(id: ID!): User + } + + type User { + id: ID! + name: String! + email: String! + } + `; + + const createProposal1Response = await client.createProposal({ + federatedGraphName: fedGraph1Name, + namespace: DEFAULT_NAMESPACE, + name: proposal1Name, + namingConvention: ProposalNamingConvention.INCREMENTAL, + origin: ProposalOrigin.INTERNAL, + subgraphs: [ + { + name: subgraph1Name, + schemaSDL: updatedSubgraph1SDL, + isDeleted: false, + isNew: false, + labels: [], + }, + ], + }); + + expect(createProposal1Response.response?.code).toBe(EnumStatusCode.OK); + + // Create proposal for fedGraph2 + const updatedSubgraph2SDL = ` + type Query { + products: [Product!]! + product(id: ID!): Product + } + + type Product { + id: ID! + name: String! + price: Float! + } + `; + + const createProposal2Response = await client.createProposal({ + federatedGraphName: fedGraph2Name, + namespace: DEFAULT_NAMESPACE, + name: proposal2Name, + namingConvention: ProposalNamingConvention.INCREMENTAL, + origin: ProposalOrigin.INTERNAL, + subgraphs: [ + { + name: subgraph2Name, + schemaSDL: updatedSubgraph2SDL, + isDeleted: false, + isNew: false, + labels: [], + }, + ], + }); + + expect(createProposal2Response.response?.code).toBe(EnumStatusCode.OK); + + // Approve both proposals + await client.updateProposal({ + proposalName: createProposal1Response.proposalName, + federatedGraphName: fedGraph1Name, + namespace: DEFAULT_NAMESPACE, + updateAction: { + case: 'state', + value: 'APPROVED', + }, + }); + + await client.updateProposal({ + proposalName: createProposal2Response.proposalName, + federatedGraphName: fedGraph2Name, + namespace: DEFAULT_NAMESPACE, + updateAction: { + case: 'state', + value: 'APPROVED', + }, + }); + + // Clear captured webhooks before publishing (webhook configs creation may trigger some) + capturedWebhooks = []; + + // Publish subgraph1 schema - this should mark proposal1 as PUBLISHED + const publishSubgraph1Response = await client.publishFederatedSubgraph({ + name: subgraph1Name, + namespace: DEFAULT_NAMESPACE, + schema: updatedSubgraph1SDL, + }); + + expect(publishSubgraph1Response.response?.code).toBe(EnumStatusCode.OK); + + // Wait a bit for webhook to be sent + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify FEDERATED_GRAPH_SCHEMA_UPDATED webhook for fedGraph1 was called + const fedGraph1SchemaWebhooks = capturedWebhooks.filter( + (w) => w.url === 'http://webhook-fedgraph1.test' && w.payload.event === 'FEDERATED_GRAPH_SCHEMA_UPDATED', + ); + expect(fedGraph1SchemaWebhooks.length).toBeGreaterThan(0); + + // Get the latest schema updated webhook for fedGraph1 + const latestFedGraph1SchemaWebhook = fedGraph1SchemaWebhooks.at(-1)!; + + // Get the latest composition for fedGraph1 and verify schemaVersionId matches the webhook payload + const fedGraph1Compositions = await client.getCompositions({ + fedGraphName: fedGraph1Name, + namespace: DEFAULT_NAMESPACE, + startDate: formatISO(subDays(new Date(), 1)), + endDate: formatISO(addMinutes(new Date(), 1)), + }); + expect(fedGraph1Compositions.response?.code).toBe(EnumStatusCode.OK); + const latestFedGraph1Composition = fedGraph1Compositions.compositions.find((c) => c.isLatestValid); + expect(latestFedGraph1Composition).toBeDefined(); + expect(latestFedGraph1SchemaWebhook.payload.payload.federated_graph.composedSchemaVersionId).toBe( + latestFedGraph1Composition!.schemaVersionId, + ); + + // Verify PROPOSAL_STATE_UPDATED webhook was sent for proposal1 + const proposal1StateWebhooks = capturedWebhooks.filter( + (w) => + w.url === 'http://webhook-fedgraph1.test' && + w.payload.event === 'PROPOSAL_STATE_UPDATED' && + w.payload.payload?.proposal?.id === createProposal1Response.proposalId, + ); + expect(proposal1StateWebhooks.length).toBe(1); + expect(proposal1StateWebhooks[0].payload.payload.proposal.state).toBe('PUBLISHED'); + + // Verify PROPOSAL_STATE_UPDATED webhook with state 'PUBLISHED' was NOT sent for proposal2 (it's not published yet) + const proposal2PublishedWebhooksBeforePublish = capturedWebhooks.filter( + (w) => + w.payload.event === 'PROPOSAL_STATE_UPDATED' && + w.payload.payload?.proposal?.id === createProposal2Response.proposalId && + w.payload.payload?.proposal?.state === 'PUBLISHED', + ); + expect(proposal2PublishedWebhooksBeforePublish.length).toBe(0); + + // Clear captured webhooks + capturedWebhooks = []; + + // Publish subgraph2 schema - this should mark proposal2 as PUBLISHED + const publishSubgraph2Response = await client.publishFederatedSubgraph({ + name: subgraph2Name, + namespace: DEFAULT_NAMESPACE, + schema: updatedSubgraph2SDL, + }); + + expect(publishSubgraph2Response.response?.code).toBe(EnumStatusCode.OK); + + // Wait a bit for webhook to be sent + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify FEDERATED_GRAPH_SCHEMA_UPDATED webhook for fedGraph2 was called + const fedGraph2SchemaWebhooks = capturedWebhooks.filter( + (w) => w.url === 'http://webhook-fedgraph2.test' && w.payload.event === 'FEDERATED_GRAPH_SCHEMA_UPDATED', + ); + expect(fedGraph2SchemaWebhooks.length).toBeGreaterThan(0); + + // Get the latest schema updated webhook for fedGraph2 + const latestFedGraph2SchemaWebhook = fedGraph2SchemaWebhooks.at(-1)!; + + // Get the latest composition for fedGraph2 and verify schemaVersionId matches the webhook payload + const fedGraph2Compositions = await client.getCompositions({ + fedGraphName: fedGraph2Name, + namespace: DEFAULT_NAMESPACE, + startDate: formatISO(subDays(new Date(), 1)), + endDate: formatISO(addMinutes(new Date(), 1)), + }); + expect(fedGraph2Compositions.response?.code).toBe(EnumStatusCode.OK); + const latestFedGraph2Composition = fedGraph2Compositions.compositions.find((c) => c.isLatestValid); + expect(latestFedGraph2Composition).toBeDefined(); + expect(latestFedGraph2SchemaWebhook.payload.payload.federated_graph.composedSchemaVersionId).toBe( + latestFedGraph2Composition!.schemaVersionId, + ); + + // Verify PROPOSAL_STATE_UPDATED webhook was sent for proposal2 + const proposal2StateWebhooks = capturedWebhooks.filter( + (w) => + w.url === 'http://webhook-fedgraph2.test' && + w.payload.event === 'PROPOSAL_STATE_UPDATED' && + w.payload.payload?.proposal?.id === createProposal2Response.proposalId, + ); + expect(proposal2StateWebhooks.length).toBe(1); + expect(proposal2StateWebhooks[0].payload.payload.proposal.state).toBe('PUBLISHED'); + + await server.close(); + }); + + test('should include correct composedSchemaVersionId and send PROPOSAL_STATE_UPDATED webhook when deleting subgraph', async () => { + const { client, server } = await SetupTest({ + dbname, + chClient, + setupBilling: { plan: 'enterprise' }, + enabledFeatures: ['proposals'], + }); + + // Setup: Create two federated graphs, each with two subgraphs (we'll delete one from each) + const fedGraph1Name = genID('fedGraph1'); + const fedGraph2Name = genID('fedGraph2'); + const subgraph1Name = genID('subgraph1'); // Will be deleted from fedGraph1 + const subgraph2Name = genID('subgraph2'); // Stays in fedGraph1 + const subgraph3Name = genID('subgraph3'); // Will be deleted from fedGraph2 + const subgraph4Name = genID('subgraph4'); // Stays in fedGraph2 + const label1 = genUniqueLabel('label1'); + const label2 = genUniqueLabel('label2'); + const proposal1Name = genID('proposal1'); + const proposal2Name = genID('proposal2'); + + const subgraph1SchemaSDL = ` + type Query { + users: [User!]! + } + + type User { + id: ID! + name: String! + } + `; + + const subgraph2SchemaSDL = ` + type Query { + posts: [Post!]! + } + + type Post { + id: ID! + title: String! + } + `; + + const subgraph3SchemaSDL = ` + type Query { + products: [Product!]! + } + + type Product { + id: ID! + name: String! + } + `; + + const subgraph4SchemaSDL = ` + type Query { + orders: [Order!]! + } + + type Order { + id: ID! + productId: ID! + } + `; + + // Create fedGraph1 with two subgraphs + await createThenPublishSubgraph( + client, + subgraph1Name, + DEFAULT_NAMESPACE, + subgraph1SchemaSDL, + [label1], + DEFAULT_SUBGRAPH_URL_ONE, + ); + + await createThenPublishSubgraph( + client, + subgraph2Name, + DEFAULT_NAMESPACE, + subgraph2SchemaSDL, + [label1], + DEFAULT_SUBGRAPH_URL_TWO, + ); + + await createFederatedGraph(client, fedGraph1Name, DEFAULT_NAMESPACE, [joinLabel(label1)], DEFAULT_ROUTER_URL); + + // Create fedGraph2 with two subgraphs + await createThenPublishSubgraph( + client, + subgraph3Name, + DEFAULT_NAMESPACE, + subgraph3SchemaSDL, + [label2], + DEFAULT_SUBGRAPH_URL_THREE, + ); + + await createThenPublishSubgraph( + client, + subgraph4Name, + DEFAULT_NAMESPACE, + subgraph4SchemaSDL, + [label2], + 'http://localhost:4004', + ); + + await createFederatedGraph( + client, + fedGraph2Name, + DEFAULT_NAMESPACE, + [joinLabel(label2)], + 'http://localhost:3003', + ); + + // Get federated graph IDs + const fedGraph1Res = await client.getFederatedGraphByName({ + name: fedGraph1Name, + namespace: DEFAULT_NAMESPACE, + }); + const fedGraph1Id = fedGraph1Res.graph!.id; + + const fedGraph2Res = await client.getFederatedGraphByName({ + name: fedGraph2Name, + namespace: DEFAULT_NAMESPACE, + }); + const fedGraph2Id = fedGraph2Res.graph!.id; + + // Create webhook configs + await client.createOrganizationWebhookConfig({ + endpoint: 'http://webhook-fedgraph1.test', + events: [ + OrganizationEventName[OrganizationEventName.FEDERATED_GRAPH_SCHEMA_UPDATED], + OrganizationEventName[OrganizationEventName.PROPOSAL_STATE_UPDATED], + ], + eventsMeta: [ + { + eventName: OrganizationEventName.FEDERATED_GRAPH_SCHEMA_UPDATED, + meta: { + case: 'federatedGraphSchemaUpdated', + value: { graphIds: [fedGraph1Id] }, + }, + }, + { + eventName: OrganizationEventName.PROPOSAL_STATE_UPDATED, + meta: { + case: 'proposalStateUpdated', + value: { graphIds: [fedGraph1Id] }, + }, + }, + ], + }); + + await client.createOrganizationWebhookConfig({ + endpoint: 'http://webhook-fedgraph2.test', + events: [ + OrganizationEventName[OrganizationEventName.FEDERATED_GRAPH_SCHEMA_UPDATED], + OrganizationEventName[OrganizationEventName.PROPOSAL_STATE_UPDATED], + ], + eventsMeta: [ + { + eventName: OrganizationEventName.FEDERATED_GRAPH_SCHEMA_UPDATED, + meta: { + case: 'federatedGraphSchemaUpdated', + value: { graphIds: [fedGraph2Id] }, + }, + }, + { + eventName: OrganizationEventName.PROPOSAL_STATE_UPDATED, + meta: { + case: 'proposalStateUpdated', + value: { graphIds: [fedGraph2Id] }, + }, + }, + ], + }); + + // Enable proposals + await enableProposalsForNamespace(client); + + // Create proposal for fedGraph1 that deletes subgraph1 + const createProposal1Response = await client.createProposal({ + federatedGraphName: fedGraph1Name, + namespace: DEFAULT_NAMESPACE, + name: proposal1Name, + namingConvention: ProposalNamingConvention.INCREMENTAL, + origin: ProposalOrigin.INTERNAL, + subgraphs: [ + { + name: subgraph1Name, + schemaSDL: subgraph1SchemaSDL, + isDeleted: true, + isNew: false, + labels: [], + }, + ], + }); + + expect(createProposal1Response.response?.code).toBe(EnumStatusCode.OK); + + // Create proposal for fedGraph2 that deletes subgraph3 + const createProposal2Response = await client.createProposal({ + federatedGraphName: fedGraph2Name, + namespace: DEFAULT_NAMESPACE, + name: proposal2Name, + namingConvention: ProposalNamingConvention.INCREMENTAL, + origin: ProposalOrigin.INTERNAL, + subgraphs: [ + { + name: subgraph3Name, + schemaSDL: subgraph3SchemaSDL, + isDeleted: true, + isNew: false, + labels: [], + }, + ], + }); + + expect(createProposal2Response.response?.code).toBe(EnumStatusCode.OK); + + // Approve both proposals + await client.updateProposal({ + proposalName: createProposal1Response.proposalName, + federatedGraphName: fedGraph1Name, + namespace: DEFAULT_NAMESPACE, + updateAction: { + case: 'state', + value: 'APPROVED', + }, + }); + + await client.updateProposal({ + proposalName: createProposal2Response.proposalName, + federatedGraphName: fedGraph2Name, + namespace: DEFAULT_NAMESPACE, + updateAction: { + case: 'state', + value: 'APPROVED', + }, + }); + + // Clear captured webhooks before deleting + capturedWebhooks = []; + + // Delete subgraph1 - this should mark proposal1 as PUBLISHED + const deleteSubgraph1Response = await client.deleteFederatedSubgraph({ + subgraphName: subgraph1Name, + namespace: DEFAULT_NAMESPACE, + }); + + expect(deleteSubgraph1Response.response?.code).toBe(EnumStatusCode.OK); + + // Wait a bit for webhook to be sent + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify FEDERATED_GRAPH_SCHEMA_UPDATED webhook for fedGraph1 was called + const fedGraph1SchemaWebhooks = capturedWebhooks.filter( + (w) => w.url === 'http://webhook-fedgraph1.test' && w.payload.event === 'FEDERATED_GRAPH_SCHEMA_UPDATED', + ); + expect(fedGraph1SchemaWebhooks.length).toBeGreaterThan(0); + + const latestFedGraph1SchemaWebhook = fedGraph1SchemaWebhooks.at(-1)!; + + // Get the latest composition for fedGraph1 and verify schemaVersionId matches the webhook payload + const fedGraph1Compositions = await client.getCompositions({ + fedGraphName: fedGraph1Name, + namespace: DEFAULT_NAMESPACE, + startDate: formatISO(subDays(new Date(), 1)), + endDate: formatISO(addMinutes(new Date(), 1)), + }); + expect(fedGraph1Compositions.response?.code).toBe(EnumStatusCode.OK); + const latestFedGraph1Composition = fedGraph1Compositions.compositions.find((c) => c.isLatestValid); + expect(latestFedGraph1Composition).toBeDefined(); + expect(latestFedGraph1SchemaWebhook.payload.payload.federated_graph.composedSchemaVersionId).toBe( + latestFedGraph1Composition!.schemaVersionId, + ); + + // Verify PROPOSAL_STATE_UPDATED webhook was sent for proposal1 + const proposal1StateWebhooks = capturedWebhooks.filter( + (w) => + w.url === 'http://webhook-fedgraph1.test' && + w.payload.event === 'PROPOSAL_STATE_UPDATED' && + w.payload.payload?.proposal?.id === createProposal1Response.proposalId, + ); + expect(proposal1StateWebhooks.length).toBe(1); + expect(proposal1StateWebhooks[0].payload.payload.proposal.state).toBe('PUBLISHED'); + + // Verify PROPOSAL_STATE_UPDATED webhook with state 'PUBLISHED' was NOT sent for proposal2 (it's not published yet) + const proposal2PublishedWebhooksBeforeDelete = capturedWebhooks.filter( + (w) => + w.payload.event === 'PROPOSAL_STATE_UPDATED' && + w.payload.payload?.proposal?.id === createProposal2Response.proposalId && + w.payload.payload?.proposal?.state === 'PUBLISHED', + ); + expect(proposal2PublishedWebhooksBeforeDelete.length).toBe(0); + + // Clear captured webhooks + capturedWebhooks = []; + + // Delete subgraph3 - this should mark proposal2 as PUBLISHED + const deleteSubgraph3Response = await client.deleteFederatedSubgraph({ + subgraphName: subgraph3Name, + namespace: DEFAULT_NAMESPACE, + }); + + expect(deleteSubgraph3Response.response?.code).toBe(EnumStatusCode.OK); + + // Wait a bit for webhook to be sent + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify FEDERATED_GRAPH_SCHEMA_UPDATED webhook for fedGraph2 was called + const fedGraph2SchemaWebhooks = capturedWebhooks.filter( + (w) => w.url === 'http://webhook-fedgraph2.test' && w.payload.event === 'FEDERATED_GRAPH_SCHEMA_UPDATED', + ); + expect(fedGraph2SchemaWebhooks.length).toBeGreaterThan(0); + + const latestFedGraph2SchemaWebhook = fedGraph2SchemaWebhooks.at(-1)!; + + // Get the latest composition for fedGraph2 and verify schemaVersionId matches the webhook payload + const fedGraph2Compositions = await client.getCompositions({ + fedGraphName: fedGraph2Name, + namespace: DEFAULT_NAMESPACE, + startDate: formatISO(subDays(new Date(), 1)), + endDate: formatISO(addMinutes(new Date(), 1)), + }); + expect(fedGraph2Compositions.response?.code).toBe(EnumStatusCode.OK); + const latestFedGraph2Composition = fedGraph2Compositions.compositions.find((c) => c.isLatestValid); + expect(latestFedGraph2Composition).toBeDefined(); + expect(latestFedGraph2SchemaWebhook.payload.payload.federated_graph.composedSchemaVersionId).toBe( + latestFedGraph2Composition!.schemaVersionId, + ); + + // Verify PROPOSAL_STATE_UPDATED webhook was sent for proposal2 + const proposal2StateWebhooks = capturedWebhooks.filter( + (w) => + w.url === 'http://webhook-fedgraph2.test' && + w.payload.event === 'PROPOSAL_STATE_UPDATED' && + w.payload.payload?.proposal?.id === createProposal2Response.proposalId, + ); + expect(proposal2StateWebhooks.length).toBe(1); + expect(proposal2StateWebhooks[0].payload.payload.proposal.state).toBe('PUBLISHED'); + + await server.close(); + }); +});