mirror of
https://github.com/graphql-hive/console
synced 2026-05-03 21:48:18 +00:00
812 lines
21 KiB
TypeScript
812 lines
21 KiB
TypeScript
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 (syntax error)', 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('Expected Name');
|
|
});
|
|
|
|
test.concurrent('rejected: object type passed to input argument', 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',
|
|
});
|
|
|
|
await check({
|
|
sdl: /* GraphQL */ `
|
|
type Query {
|
|
topProduct(filter: TopProductFilter): Product
|
|
}
|
|
|
|
type Product @key(selectionSet: "{ id }") {
|
|
id: ID!
|
|
name: String
|
|
}
|
|
|
|
type TopProductFilter {
|
|
year: Int
|
|
category: String
|
|
}
|
|
`,
|
|
serviceName: 'products',
|
|
expect: 'rejected',
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
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,
|
|
};
|
|
}
|