import { ProjectType } from 'testkit/gql/graphql'; import { normalizeCliOutput } from '../../../scripts/serializers/cli-output'; import { createCLI } from '../../testkit/cli'; import { prepareProject } from '../../testkit/registry-models'; const cases = [ ['default' as const, [] as [string, boolean][]], [ 'compareToPreviousComposableVersion' as const, [['compareToPreviousComposableVersion', true]] as [string, boolean][], ], ] as Array<['default' | 'compareToPreviousComposableVersion', Array<[string, boolean]>]>; describe('publish', () => { describe.concurrent.each(cases)('%s', (caseName, ffs) => { test.concurrent('accepted: composable', async () => { const { cli: { publish }, } = await prepare(ffs); await publish({ sdl: `type Query { topProductName: String }`, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', }); }); test.concurrent('accepted: composable, breaking changes', async () => { const { cli: { publish }, } = await prepare(ffs); await publish({ sdl: /* GraphQL */ ` type Query { topProductName: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', }); await publish({ sdl: /* GraphQL */ ` type Query { nooooo: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', }); }); test.concurrent( `${caseName === 'default' ? 'rejected' : 'accepted'}: not composable (build errors)`, async () => { const { cli: { publish }, } = await prepare(ffs); await publish({ sdl: /* GraphQL */ ` type Query { topProductName: UnknownType } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: caseName === 'default' ? 'rejected' : 'latest', }); }, ); test.concurrent('accepted: composable, previous version was not', async () => { const { cli: { publish }, } = await prepare(ffs); // non-composable await publish({ sdl: /* GraphQL */ ` type Query { product(id: ID!): Product } type Product @key(selectionSet: "{ id") { id: ID! name: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest', }); // composable await publish({ sdl: /* GraphQL */ ` type Query { product(id: ID!): Product } type Product @key(selectionSet: "{ id }") { id: ID! name: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', }); }); test.concurrent('accepted: composable, no changes', async () => { const { cli: { publish }, } = await prepare(ffs); // composable await publish({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', metadata: { products: 3000 }, expect: 'latest-composable', }); // composable but no changes await publish({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', metadata: { products: 3000 }, expect: 'ignored', }); }); test.concurrent('accepted: composable, new url', async () => { const { cli: { publish }, } = await prepare(ffs); // composable await publish({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', }); // composable, no changes, only url is different await publish({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceName: 'products', serviceUrl: 'http://products:4321/graphql', // new url expect: 'latest-composable', }); }); test.concurrent('accepted: composable, new metadata', async () => { const { cli: { publish }, } = await prepare(ffs); // composable await publish({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', metadata: { version: 'v1' }, expect: 'latest-composable', }); // composable, no changes, only metadata is different await publish({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', metadata: { version: 'v2' }, // new metadata expect: 'latest-composable', }); }); test.concurrent('rejected: missing service name', async () => { const { cli: { publish }, } = await prepare(ffs); // composable await publish({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceUrl: 'http://products:3000/graphql', expect: 'rejected', }); }); test.concurrent('rejected: missing service url', async () => { const { cli: { publish }, } = await prepare(ffs); // composable await publish({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceName: 'products', expect: 'rejected', }); }); test.concurrent('CLI output', async ({ expect }) => { const { cli: { publish }, } = await prepare(ffs); const service = { serviceName: 'products', serviceUrl: 'http://products:3000/graphql', }; let output = normalizeCliOutput( (await publish({ sdl: /* GraphQL */ ` type Query { topProduct: Product } type Product { id: ID! name: String! } `, ...service, expect: 'latest-composable', })) ?? '', ); expect(output).toEqual(expect.stringContaining('v Published initial schema.')); expect(output).toEqual( expect.stringContaining('i Available at $appUrl/$organization/$project/$target'), ); output = normalizeCliOutput( (await publish({ sdl: /* GraphQL */ ` type Query { topProduct: Product } type Product { id: ID! name: String! price: Int! } `, ...service, expect: 'latest-composable', })) ?? '', ); expect(output).toEqual(expect.stringContaining(`v Schema published`)); expect(output).toEqual( expect.stringContaining( `i Available at $appUrl/$organization/$project/$target/history/$version`, ), ); }); }); describe('check', () => { describe.concurrent.each(cases)('%s', (caseName, ffs) => { test.concurrent('accepted: composable, no breaking changes', async () => { const { cli: { publish, check }, } = await prepare(ffs); await publish({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', }); const message = await check({ sdl: /* GraphQL */ ` type Query { topProduct: String topProductName: String } `, serviceName: 'products', expect: 'approved', }); expect(message).toMatch('topProductName'); }); test.concurrent('accepted: composable, previous version was not', async () => { const { cli: { publish, check }, } = await prepare(ffs); await publish({ sdl: /* GraphQL */ ` type Query { product(id: ID!): Product } type Product @key(selectionSet: "{ id") { id: ID! name: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest', }); const message = await check({ sdl: /* GraphQL */ ` type Query { product(id: ID!): Product } type Product @key(selectionSet: "{ id }") { id: ID! name: String } `, serviceName: 'products', expect: 'approved', }); expect(message).toMatch('No changes'); }); test.concurrent('accepted: no changes', async () => { const { cli: { publish, check }, } = await prepare(ffs); await publish({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', }); await check({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceName: 'products', expect: 'approved', }); }); test.concurrent('rejected: missing service name', async () => { const { cli: { check }, } = await prepare(ffs); const message = await check({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, expect: 'rejected', }); expect(message).toMatch('name'); }); test.concurrent('rejected: composable, breaking changes', async () => { const { cli: { publish, check }, } = await prepare(ffs); await publish({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', }); const message = await check({ sdl: /* GraphQL */ ` type Query { topProductName: String } `, serviceName: 'products', expect: 'rejected', }); expect(message).toMatch('removed'); }); test.concurrent('rejected: not composable, no breaking changes', async () => { const { cli: { publish, check }, } = await prepare(ffs); await publish({ sdl: /* GraphQL */ ` type Query { topProduct: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', }); const message = await check({ sdl: /* GraphQL */ ` type Query { topProduct: String topProductName: Strin } `, serviceName: 'products', expect: 'rejected', }); expect(message).toMatch('Str'); }); test.concurrent('rejected: not composable, breaking changes', async () => { const { cli: { publish, check }, } = await prepare(ffs); await publish({ sdl: /* GraphQL */ ` type Query { topProduct: Product } type Product @key(selectionSet: "{ id }") { id: ID! name: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', }); const message = await check({ sdl: /* GraphQL */ ` type Query { product(id: ID!): Product } type Product @key(selectionSet: "{ id") { id: ID! name: String } `, serviceName: 'products', expect: 'rejected', }); expect(message).toMatch('topProduct'); expect(message).toMatch('Expected Name'); }); }); }); }); describe('delete', () => { describe.concurrent.each(cases)('%s', (caseName, ffs) => { test.concurrent('accepted: composable before and after', async () => { const { cli } = await prepare(ffs); await cli.publish({ sdl: /* GraphQL */ ` type Query { topProduct: Product } type Product @key(selectionSet: "{ id }") { id: ID! name: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', }); await cli.publish({ sdl: /* GraphQL */ ` type Query { topReview: Review } type Review @key(selectionSet: "{ id }") { id: ID! title: String } `, serviceName: 'reviews', serviceUrl: 'http://reviews:3000/graphql', expect: 'latest-composable', }); const message = await cli.delete({ serviceName: 'reviews', expect: 'latest-composable', }); expect(message).toMatch('reviews deleted'); }); test.concurrent('rejected: unknown service', async () => { const { cli } = await prepare(ffs); await cli.publish({ sdl: /* GraphQL */ ` type Query { topProduct: Product } type Product @key(selectionSet: "{ id }") { id: ID! name: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', }); const message = await cli.delete({ serviceName: 'unknown_service', expect: 'rejected', }); expect(message).toMatch('not found'); }); }); }); describe('other', () => { describe.concurrent.each(cases)('%s', (_, ffs) => { test.concurrent( 'publish new schema when a field is moved from one service to another', async () => { const { tokens, fetchVersions, setFeatureFlag } = await prepareProject( ProjectType.Stitching, ); for await (const [name, enabled] of ffs) { await setFeatureFlag(name, enabled); } const { publish } = createCLI(tokens.registry); // cats service has only one field await publish({ sdl: /* GraphQL */ ` type Query { randomCat: String } `, serviceName: 'cats', serviceUrl: 'http://cats.com/graphql', expect: 'latest-composable', }); // dogs service has two fields await publish({ sdl: /* GraphQL */ ` type Query { randomDog: String randomAnimal: String } `, serviceName: 'dogs', serviceUrl: 'http://dogs.com/graphql', expect: 'latest-composable', }); // cats service has now two fields, randomAnimal is borrowed from dogs service await publish({ sdl: /* GraphQL */ ` type Query { randomCat: String randomAnimal: String } `, serviceName: 'cats', serviceUrl: 'http://cats.com/graphql', expect: 'latest-composable', // We expect to have a new version, even tough the schema (merged) is the same }); const versionsResult = await fetchVersions(3); expect(versionsResult).toHaveLength(3); }, ); test.concurrent( 'ignore stitching directive validation if the service overrides the stitching directive', async () => { const [spec, custom] = await Promise.all([prepare(ffs), prepare(ffs)]); // Make sure validation works by publishing a schema // with a stitching directive with incomplete selectionSet argument await spec.cli.publish({ sdl: /* GraphQL */ ` type Query { topProduct: Product } type Product @key(selectionSet: "{ id ") { id: ID! name: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest', // it's not composable because of the invalid selectionSet }); // Stitching directive with incomplete selectionSet argument but a definition of @key await custom.cli.publish({ sdl: /* GraphQL */ ` directive @key(selectionSet: String) on OBJECT type Query { topProduct: Product } type Product @key(selectionSet: "{ id ") { id: ID! name: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', expect: 'latest-composable', // it should be composable because the validation is skipped }); }, ); test.concurrent('metadata should always be published as an array', async () => { const { cli, cdn } = await prepare(ffs); await cli.publish({ sdl: /* GraphQL */ ` type Query { topProduct: Product } type Product @key(selectionSet: "{ id }") { id: ID! name: String } `, serviceName: 'products', serviceUrl: 'http://products:3000/graphql', metadata: { products: 'v1' }, expect: 'latest-composable', }); await expect(cdn.fetchMetadata()).resolves.toEqual( expect.objectContaining({ status: 200, body: [{ products: 'v1' }], // array }), ); await cli.publish({ sdl: /* GraphQL */ ` type Query { topReview: Review } type Review @key(selectionSet: "{ id }") { id: ID! title: String } `, serviceName: 'reviews', serviceUrl: 'http://reviews:3000/graphql', metadata: { reviews: 'v1' }, expect: 'latest-composable', }); await expect(cdn.fetchMetadata()).resolves.toEqual( expect.objectContaining({ status: 200, body: [{ products: 'v1' }, { reviews: 'v1' }], // array }), ); await cli.delete({ serviceName: 'reviews', expect: 'latest-composable', }); await expect(cdn.fetchMetadata()).resolves.toEqual( expect.objectContaining({ status: 200, body: [{ products: 'v1' }], // array }), ); }); }); }); async function prepare(featureFlags: Array<[string, boolean]> = []) { const { tokens, setFeatureFlag, cdn } = await prepareProject(ProjectType.Stitching); for await (const [name, enabled] of featureFlags) { await setFeatureFlag(name, enabled); } return { cli: createCLI(tokens.registry), cdn, }; }