feat: federation 2 service container (#743)

This commit is contained in:
Laurin Quast 2022-12-06 12:01:03 +01:00 committed by GitHub
parent ca9d60d4df
commit f0735fad28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 462 additions and 3 deletions

View file

@ -239,6 +239,7 @@ jobs:
packages/services/usage-ingestor/dist
packages/services/usage/dist
packages/services/webhooks/dist
packages/services/external-composition/federation-2/dist
packages/web/app/dist.zip
rust:

View file

@ -260,6 +260,23 @@ target "webhooks" {
]
}
target "composition-federation-2" {
inherits = ["service-base", get_target()]
context = "${PWD}/packages/services/external-composition/federation-2/dist"
args = {
IMAGE_TITLE = "graphql-hive/composition-federation-2"
IMAGE_DESCRIPTION = "Federation 2 Composition Service for GraphQL Hive."
PORT = "3069"
HEALTHCHECK_CMD = "wget --spider -q http://127.0.0.1:$${PORT}/_readiness"
}
tags = [
local_image_tag("composition-federation-2"),
stable_image_tag("composition-federation-2"),
image_tag("composition-federation-2", COMMIT_SHA),
image_tag("composition-federation-2", BRANCH_NAME)
]
}
target "app" {
inherits = ["app-base", get_target()]
context = "${PWD}/packages/web/app/dist"
@ -291,6 +308,7 @@ group "build" {
"webhooks",
"server",
"stripe-billing",
"composition-federation-2",
"app"
]
}

View file

@ -224,6 +224,23 @@ services:
PORT: 3013
CF_BROKER_SIGNATURE: secretSignature
composition_federation_2:
image: '${DOCKER_REGISTRY}composition-federation-2${DOCKER_TAG}'
networks:
- 'stack'
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'localhost:3069/_readiness']
interval: 5s
timeout: 5s
retries: 6
start_period: 5s
ports:
- 3069:3069
environment:
NODE_ENV: production
PORT: 3069
SECRET: secretsecret
external_composition:
image: node:16.15.1-alpine3.14
entrypoint:
@ -307,7 +324,7 @@ services:
ports:
- 3001:3001
environment:
NODE_ENV: production
NODE_ENV: development
POSTGRES_HOST: db
POSTGRES_PORT: 5432
POSTGRES_DB: registry

View file

@ -0,0 +1,98 @@
import {
createOrganization,
publishSchema,
createProject,
createToken,
enableExternalSchemaComposition,
} from '../../../testkit/flow';
import { authenticate } from '../../../testkit/auth';
import { TargetAccessScope, ProjectType, ProjectAccessScope } from '@app/gql/graphql';
const dockerAddress = 'composition_federation_2:3069';
test('call an external service to compose and validate services', async () => {
const { access_token: owner_access_token } = await authenticate('main');
const orgResult = await createOrganization(
{
name: 'foo',
},
owner_access_token,
);
const org = orgResult.body.data!.createOrganization.ok!.createdOrganizationPayload.organization;
const projectResult = await createProject(
{
organization: org.cleanId,
type: ProjectType.Federation,
name: 'bar',
},
owner_access_token,
);
const project = projectResult.body.data!.createProject.ok!.createdProject;
const target = projectResult.body.data!.createProject.ok!.createdTargets[0];
// Create a token with write rights
const writeTokenResult = await createToken(
{
name: 'test',
organization: org.cleanId,
project: project.cleanId,
target: target.cleanId,
organizationScopes: [],
projectScopes: [ProjectAccessScope.Settings, ProjectAccessScope.Read],
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
},
owner_access_token,
);
expect(writeTokenResult.body.errors).not.toBeDefined();
const writeToken = writeTokenResult.body.data!.createToken.ok!.secret;
const usersServiceName = Math.random().toString(16).substring(2);
const publishUsersResult = await publishSchema(
{
author: 'Kamil',
commit: 'init',
url: 'https://api.com/users',
sdl: `type Query { me: User } type User @key(fields: "id") { id: ID! name: String }`,
service: usersServiceName,
},
writeToken,
);
// Schema publish should be successful
expect(publishUsersResult.body.errors).not.toBeDefined();
expect(publishUsersResult.body.data!.schemaPublish.__typename).toBe('SchemaPublishSuccess');
// enable external composition
const externalCompositionResult = await enableExternalSchemaComposition(
{
endpoint: `http://${dockerAddress}/compose`,
// eslint-disable-next-line no-process-env
secret: process.env.EXTERNAL_COMPOSITION_SECRET!,
project: project.cleanId,
organization: org.cleanId,
},
writeToken,
);
expect(externalCompositionResult.body.errors).not.toBeDefined();
expect(externalCompositionResult.body.data!.enableExternalSchemaComposition.ok?.endpoint).toBe(
`http://${dockerAddress}/compose`,
);
const productsServiceName = Math.random().toString(16).substring(2);
const publishProductsResult = await publishSchema(
{
author: 'Kamil',
commit: 'init',
url: 'https://api.com/products',
sdl: `type Query { products: [Product] } type Product @key(fields: "id") { id: ID! name: String }`,
service: productsServiceName,
},
writeToken,
);
// Schema publish should be successful
expect(publishProductsResult.body.errors).not.toBeDefined();
expect(publishProductsResult.body.data!.schemaPublish.__typename).toBe('SchemaPublishSuccess');
});

View file

@ -22,7 +22,7 @@
"build": "pnpm graphql:generate && pnpm turbo build --color",
"build:libraries": "pnpm graphql:generate && pnpm turbo build --filter=./packages/libraries/* --color",
"build:local": "pnpm graphql:generate && pnpm turbo build-local --color",
"build:services": "pnpm graphql:generate && pnpm turbo build --filter=./packages/services/* --color",
"build:services": "pnpm graphql:generate && pnpm turbo build --filter=./packages/services/**/* --color",
"build:web": "pnpm graphql:generate && pnpm turbo build --filter=./packages/web/* --color",
"docker:build": "docker buildx bake -f docker.hcl --load build",
"env:sync": "node ./scripts/sync-env-files.js",

View file

@ -0,0 +1 @@
# Composition Service for Apollo Federation 2

View file

@ -0,0 +1,26 @@
{
"name": "@hive/external-composition-federation-2",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"build": "bob runify --single",
"dev": "tsup-node src/dev.ts --format esm --shims --target node16 --watch --sourcemap --onSuccess 'node --enable-source-maps dist/dev.js' | pino-pretty --translateTime HH:MM:ss TT --ignore pid,hostname"
},
"dependencies": {
"@apollo/composition": "^2.2.1",
"@whatwg-node/fetch": "^0.5.3",
"@whatwg-node/server": "^0.4.17",
"graphql": "^16.6.0",
"zod": "^3.15.1"
},
"devDependencies": {
"@graphql-hive/external-composition": "workspace:*"
},
"buildOptions": {
"runify": true,
"tsup": true,
"tags": [],
"banner": "../../../../scripts/banner.js"
}
}

View file

@ -0,0 +1,54 @@
import zod from 'zod';
// treat an empty string (`''`) as undefined
const emptyString = <T extends zod.ZodType>(input: T) => {
return zod.preprocess((value: unknown) => {
if (value === '') return undefined;
return value;
}, input);
};
function extractConfig<Input, Output>(config: zod.SafeParseReturnType<Input, Output>): Output {
if (!config.success) {
throw new Error('Something went wrong.');
}
return config.data;
}
const BaseSchema = zod.object({
NODE_ENV: zod.string(),
ENVIRONMENT: zod.string(),
RELEASE: emptyString(zod.string().optional()),
PORT: zod.string(),
SECRET: zod.string(),
});
const configs = {
// eslint-disable-next-line no-process-env
base: BaseSchema.safeParse(process.env),
};
const environmentErrors: Array<string> = [];
for (const config of Object.values(configs)) {
if (config.success === false) {
environmentErrors.push(JSON.stringify(config.error.format(), null, 4));
}
}
if (environmentErrors.length) {
const fullError = environmentErrors.join(`\n`);
console.error('❌ Invalid environment variables:', fullError);
process.exit(1);
}
const base = extractConfig(configs.base);
export const env = {
environment: base.ENVIRONMENT,
release: base.RELEASE ?? 'local',
http: {
port: base.PORT ?? 5000,
},
secret: base.SECRET,
};

View file

@ -0,0 +1,100 @@
import { verifyRequest, compose, signatureHeaderName } from '@graphql-hive/external-composition';
import { composeServices } from '@apollo/composition';
import { parse, printSchema } from 'graphql';
import { createServer } from 'node:http';
import process from 'node:process';
import { createServerAdapter } from '@whatwg-node/server';
import { Response } from '@whatwg-node/fetch';
import { env } from './environment';
const composeFederation = compose(services => {
const result = composeServices(
services.map(service => {
return {
typeDefs: parse(service.sdl),
name: service.name,
url: service.url,
};
}),
);
if (result.errors?.length) {
return {
type: 'failure',
result: {
errors: result.errors.map(error => ({
message: error.message,
})),
},
};
} else {
return {
type: 'success',
result: {
// TODO: verify why supergraphSdl can be undefine :)
supergraph: result.supergraphSdl!,
// TODO: verify why schema can be undefine :)
sdl: printSchema(result.schema!.toGraphQLJSSchema()),
},
};
}
});
const requestListener = createServerAdapter(async request => {
const url = new URL(request.url);
if (url.pathname === '/_readiness') {
return new Response('Ok.', {
status: 200,
});
}
if (request.method === 'POST' && url.pathname === '/compose') {
const signatureHeaderValue = request.headers.get(signatureHeaderName);
if (signatureHeaderValue === null) {
return new Response(`Missing signature header '${signatureHeaderName}'.`, { status: 400 });
}
const body = await request.text();
const error = verifyRequest({
// Stringified body, or raw body if you have access to it
body,
// Pass here the signature from `X-Hive-Signature-256` header
signature: signatureHeaderValue,
// Pass here the secret you configured in GraphQL Hive
secret: env.secret,
});
if (error) {
return new Response(error, { status: 500 });
} else {
const result = composeFederation(JSON.parse(body));
return new Response(JSON.stringify(result), {
status: 200,
headers: {
'content-type': 'application/json',
},
});
}
}
return new Response('', {
status: 404,
});
});
const server = createServer(requestListener);
server.listen(env.http.port, () => {
console.log(`Listening on http://localhost:${env.http.port}`);
});
process.on('SIGINT', () => {
server.close(err => {
if (err) {
console.error(err);
process.exit(1);
}
});
});

View file

@ -0,0 +1,9 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"module": "esnext",
"rootDir": "../../.."
},
"files": ["src/index.ts"]
}

View file

@ -528,6 +528,23 @@ importers:
pino-pretty: 6.0.0
tslib: 2.4.1
packages/services/external-composition/federation-2:
specifiers:
'@apollo/composition': ^2.2.1
'@graphql-hive/external-composition': workspace:*
'@whatwg-node/fetch': ^0.5.3
'@whatwg-node/server': ^0.4.17
graphql: 16.6.0
zod: ^3.15.1
dependencies:
'@apollo/composition': 2.2.1_graphql@16.6.0
'@whatwg-node/fetch': 0.5.3
'@whatwg-node/server': 0.4.17
graphql: 16.6.0
zod: 3.19.1
devDependencies:
'@graphql-hive/external-composition': link:../../../libraries/external-composition
packages/services/police-worker:
specifiers:
'@cloudflare/workers-types': 3.4.0
@ -1385,6 +1402,28 @@ packages:
dependencies:
graphql: 16.6.0
/@apollo/composition/2.2.1_graphql@16.6.0:
resolution: {integrity: sha512-xmd4NiCU+rW6elTZuIflR+GzZf626fJX7NVraTHXgDL8pZ7eluPal/T6vsQEg+Cb9tlAUjDhCB4RqVYPgV69RQ==}
engines: {node: '>=14.15.0'}
peerDependencies:
graphql: ^16.5.0
dependencies:
'@apollo/federation-internals': 2.2.1_graphql@16.6.0
'@apollo/query-graphs': 2.2.1_graphql@16.6.0
graphql: 16.6.0
dev: false
/@apollo/federation-internals/2.2.1_graphql@16.6.0:
resolution: {integrity: sha512-T+nxIhppu/5jNFIhfzYERIhgDvp2QqbSqnQpViDNEmK/iPKsXLFtOedZZgsAUpMIQoUedNNNuceTE9+lDUn+bA==}
engines: {node: '>=14.15.0'}
peerDependencies:
graphql: ^16.5.0
dependencies:
chalk: 4.1.2
graphql: 16.6.0
js-levenshtein: 1.1.6
dev: false
/@apollo/federation/0.38.1:
resolution: {integrity: sha512-miifyAEsFgiYKeM3lUHFH6+vKa2vm9dXKSyWVpX6oeJiPblFLe2/iByN3psZQO2sRdVqO1OKYrGXdgKc74XDKw==}
engines: {node: '>=12.13.0'}
@ -1430,6 +1469,20 @@ packages:
'@types/node': 10.17.60
long: 4.0.0
/@apollo/query-graphs/2.2.1_graphql@16.6.0:
resolution: {integrity: sha512-lDK1HfsJlM4P2AcdhNYxXnq5zWohyLiWVyhC0SEnhR/BRfoP2UOC31MHK0waRLAQP5SBMBVolRn6AhtSFNmEyA==}
engines: {node: '>=14.15.0'}
peerDependencies:
graphql: ^16.5.0
dependencies:
'@apollo/federation-internals': 2.2.1_graphql@16.6.0
'@types/uuid': 8.3.4
deep-equal: 2.1.0
graphql: 16.6.0
ts-graphviz: 0.16.0
uuid: 9.0.0
dev: false
/@apollo/subgraph/0.6.1:
resolution: {integrity: sha512-w/6FoubSxuzXSx8uvLE1wEuHZVHRXFyfHPKdM76wX5U/xw82zlUKseVO7wTuVODTcnUzEA30udYeCApUoC3/Xw==}
engines: {node: '>=12.13.0'}
@ -11050,7 +11103,6 @@ packages:
/@types/uuid/8.3.4:
resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==}
dev: true
/@types/verror/1.10.6:
resolution: {integrity: sha512-NNm+gdePAX1VGvPcGZCDKQZKYSiAWigKhKaz5KF94hG6f2s8de9Ow5+7AbXoeKxL8gavZfk4UquSAygOF2duEQ==}
@ -14458,6 +14510,26 @@ packages:
resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
dev: true
/deep-equal/2.1.0:
resolution: {integrity: sha512-2pxgvWu3Alv1PoWEyVg7HS8YhGlUFUV7N5oOvfL6d+7xAmLSemMwv/c8Zv/i9KFzxV5Kt5CAvQc70fLwVuf4UA==}
dependencies:
call-bind: 1.0.2
es-get-iterator: 1.1.2
get-intrinsic: 1.1.3
is-arguments: 1.1.1
is-date-object: 1.0.5
is-regex: 1.1.4
isarray: 2.0.5
object-is: 1.1.5
object-keys: 1.1.1
object.assign: 4.1.4
regexp.prototype.flags: 1.4.3
side-channel: 1.0.4
which-boxed-primitive: 1.0.2
which-collection: 1.0.1
which-typed-array: 1.1.8
dev: false
/deep-extend/0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
@ -14943,6 +15015,19 @@ packages:
/es-array-method-boxes-properly/1.0.0:
resolution: {integrity: sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==}
/es-get-iterator/1.1.2:
resolution: {integrity: sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==}
dependencies:
call-bind: 1.0.2
get-intrinsic: 1.1.3
has-symbols: 1.0.3
is-arguments: 1.1.1
is-map: 2.0.2
is-set: 2.0.2
is-string: 1.0.7
isarray: 2.0.5
dev: false
/es-shim-unscopables/1.0.0:
resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==}
dependencies:
@ -18058,6 +18143,10 @@ packages:
tslib: 2.4.1
dev: true
/is-map/2.0.2:
resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==}
dev: false
/is-module/1.0.0:
resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
dev: true
@ -18158,6 +18247,10 @@ packages:
scoped-regex: 2.1.0
dev: true
/is-set/2.0.2:
resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==}
dev: false
/is-shared-array-buffer/1.0.2:
resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==}
dependencies:
@ -18237,11 +18330,22 @@ packages:
resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==}
dev: true
/is-weakmap/2.0.1:
resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==}
dev: false
/is-weakref/1.0.2:
resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==}
dependencies:
call-bind: 1.0.2
/is-weakset/2.0.2:
resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==}
dependencies:
call-bind: 1.0.2
get-intrinsic: 1.1.3
dev: false
/is-windows/1.0.2:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
@ -18263,6 +18367,10 @@ packages:
resolution: {integrity: sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ==}
dev: false
/isarray/2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
dev: false
/isbinaryfile/4.0.10:
resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
engines: {node: '>= 8.0.0'}
@ -18869,6 +18977,11 @@ packages:
engines: {node: '>=12'}
dev: false
/js-levenshtein/1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
dev: false
/js-sdsl/4.1.5:
resolution: {integrity: sha512-08bOAKweV2NUC1wqTtf3qZlnpOX/R2DU9ikpjOHs0H+ibQv3zpncVQg6um4uYtRtrwIX8M4Nh3ytK4HGlYAq7Q==}
dev: true
@ -21684,6 +21797,14 @@ packages:
/object-inspect/1.12.2:
resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==}
/object-is/1.1.5:
resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
define-properties: 1.1.4
dev: false
/object-keys/0.4.0:
resolution: {integrity: sha512-ncrLw+X55z7bkl5PnUvHwFK9FcGuFYo9gtjws2XtSzL+aZ8tm830P60WJ0dSmFVaSalWieW5MD7kEdnXda9yJw==}
dev: false
@ -25737,6 +25858,10 @@ packages:
resolution: {integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==}
dev: false
/ts-graphviz/0.16.0:
resolution: {integrity: sha512-3fTPO+G6bSQNvMh/XQQzyiahVLMMj9kqYO99ivUraNJ3Wp05HZOOVtRhi6w9hq7+laP1MKHjLBtGWqTeb1fcpg==}
dev: false
/ts-interface-checker/0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@ -26937,6 +27062,15 @@ packages:
is-string: 1.0.7
is-symbol: 1.0.4
/which-collection/1.0.1:
resolution: {integrity: sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==}
dependencies:
is-map: 2.0.2
is-set: 2.0.2
is-weakmap: 2.0.1
is-weakset: 2.0.2
dev: false
/which-module/2.0.0:
resolution: {integrity: sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==}
dev: true

View file

@ -1,5 +1,6 @@
packages:
- packages/services/*
- packages/services/external-composition/*
- packages/web/*
- packages/libraries/*
- integration-tests