console/integration-tests/tests/api/app-deployments.spec.ts

5663 lines
152 KiB
TypeScript

import { buildASTSchema, parse } from 'graphql';
import { createLogger } from 'graphql-yoga';
import { pollFor } from 'testkit/flow';
import { initSeed } from 'testkit/seed';
import { getServiceHost } from 'testkit/utils';
import z from 'zod';
import { createHive } from '@graphql-hive/core';
import { psql } from '@hive/postgres';
import { clickHouseInsert } from '../../testkit/clickhouse';
import { graphql } from '../../testkit/gql';
import { ResourceAssignmentModeType } from '../../testkit/gql/graphql';
import { execute } from '../../testkit/graphql';
const CreateAppDeployment = graphql(`
mutation CreateAppDeployment($input: CreateAppDeploymentInput!) {
createAppDeployment(input: $input) {
error {
message
details {
appName
appVersion
}
}
ok {
createdAppDeployment {
id
name
version
status
}
}
}
}
`);
const GetAppDeployment = graphql(`
query GetAppDeployment(
$targetSelector: TargetSelectorInput!
$appDeploymentName: String!
$appDeploymentVersion: String!
) {
target(reference: { bySelector: $targetSelector }) {
appDeployment(appName: $appDeploymentName, appVersion: $appDeploymentVersion) {
id
lastUsed
}
}
}
`);
const GetActiveAppDeployments = graphql(`
query GetActiveAppDeployments(
$targetSelector: TargetSelectorInput!
$filter: ActiveAppDeploymentsFilter!
$first: Int
$after: String
) {
target(reference: { bySelector: $targetSelector }) {
activeAppDeployments(filter: $filter, first: $first, after: $after) {
edges {
cursor
node {
id
name
version
status
createdAt
lastUsed
}
}
pageInfo {
hasNextPage
hasPreviousPage
endCursor
startCursor
}
}
}
}
`);
const AddDocumentsToAppDeployment = graphql(`
mutation AddDocumentsToAppDeployment($input: AddDocumentsToAppDeploymentInput!) {
addDocumentsToAppDeployment(input: $input) {
error {
message
details {
index
message
}
}
ok {
appDeployment {
id
name
version
status
}
}
}
}
`);
const ActivateAppDeployment = graphql(`
mutation ActivateAppDeployment($input: ActivateAppDeploymentInput!) {
activateAppDeployment(input: $input) {
error {
message
}
ok {
isSkipped
activatedAppDeployment {
id
name
version
status
}
}
}
}
`);
const RetireAppDeployment = graphql(`
mutation RetireAppDeployment($input: RetireAppDeploymentInput!) {
retireAppDeployment(input: $input) {
error {
message
}
ok {
retiredAppDeployment {
id
name
version
status
}
}
}
}
`);
const RetireAppDeploymentWithProtectionDetails = graphql(`
mutation RetireAppDeploymentWithProtectionDetails($input: RetireAppDeploymentInput!) {
retireAppDeployment(input: $input) {
error {
message
protectionDetails {
lastUsed
daysSinceLastUsed
requiredMinDaysInactive
currentTrafficPercentage
maxTrafficPercentage
}
}
ok {
retiredAppDeployment {
id
name
version
status
}
}
}
}
`);
const UpdateTargetAppDeploymentProtectionConfiguration = graphql(`
mutation UpdateTargetAppDeploymentProtectionConfiguration(
$input: UpdateTargetAppDeploymentProtectionConfigurationInput!
) {
updateTargetAppDeploymentProtectionConfiguration(input: $input) {
ok {
target {
id
appDeploymentProtectionConfiguration {
isEnabled
minDaysInactive
minDaysSinceCreation
maxTrafficPercentage
trafficPeriodDays
ruleLogic
}
}
}
error {
message
inputErrors {
minDaysInactive
maxTrafficPercentage
}
}
}
}
`);
const GetTargetAppDeploymentProtectionConfiguration = graphql(`
query GetTargetAppDeploymentProtectionConfiguration($selector: TargetSelectorInput!) {
target(reference: { bySelector: $selector }) {
id
appDeploymentProtectionConfiguration {
isEnabled
minDaysInactive
minDaysSinceCreation
maxTrafficPercentage
trafficPeriodDays
ruleLogic
}
}
}
`);
const GetPaginatedPersistedDocuments = graphql(`
query GetPaginatedPersistedDocuments(
$targetSelector: TargetSelectorInput!
$appDeploymentName: String!
$appDeploymentVersion: String!
$first: Int
$cursor: String
) {
target(reference: { bySelector: $targetSelector }) {
appDeployment(appName: $appDeploymentName, appVersion: $appDeploymentVersion) {
id
documents(first: $first, after: $cursor) {
edges {
cursor
node {
hash
body
}
}
pageInfo {
hasNextPage
}
}
}
}
}
`);
test('create app deployment, add operations, publish, access via CDN (happy path)', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, createCdnAccess } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
});
const cdnAccess = await createCdnAccess();
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment).toEqual({
error: null,
ok: {
createdAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'pending',
},
},
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
documents: [
{
hash: 'hash',
body: 'query { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment).toEqual({
error: null,
ok: {
appDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'pending',
},
},
});
const { activateAppDeployment } = await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(activateAppDeployment).toEqual({
error: null,
ok: {
activatedAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'active',
},
isSkipped: false,
},
});
const persistedOperationUrl = `${cdnAccess.cdnUrl}/apps/my-app/1.0.0/hash`;
const response = await fetch(persistedOperationUrl, {
method: 'GET',
headers: {
'X-Hive-CDN-Key': cdnAccess.secretAccessToken,
},
});
const txt = await response.text();
expect(txt).toEqual('query { hello }');
expect(response.status).toBe(200);
});
test('create app deployment with same name and version succeed if deployment is not active', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
let createAppDeployment = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
})
.then(res => res.expectNoGraphQLErrors())
.then(res => res.createAppDeployment);
expect(createAppDeployment).toEqual({
error: null,
ok: {
createdAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'pending',
},
},
});
createAppDeployment = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
})
.then(res => res.expectNoGraphQLErrors())
.then(res => res.createAppDeployment);
expect(createAppDeployment).toEqual({
error: null,
ok: {
createdAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'pending',
},
},
});
});
test('create app deployment with same name and version does not fail if deployment is active', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
let createAppDeployment = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
})
.then(res => res.expectNoGraphQLErrors())
.then(res => res.createAppDeployment);
expect(createAppDeployment).toEqual({
error: null,
ok: {
createdAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'pending',
},
},
});
const { activateAppDeployment } = await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(activateAppDeployment).toEqual({
error: null,
ok: {
activatedAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'active',
},
isSkipped: false,
},
});
createAppDeployment = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
})
.then(res => res.expectNoGraphQLErrors())
.then(res => res.createAppDeployment);
expect(createAppDeployment).toEqual({
error: null,
ok: {
createdAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'active',
},
},
});
});
test('create app deployment fails if app name is empty', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: '',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment).toEqual({
error: {
details: {
appName: 'Must be at least 1 character long',
appVersion: null,
},
message: 'Invalid input',
},
ok: null,
});
});
test('create app deployment fails if app name exceeds length of 256 characters', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: new Array(257).fill('a').join(''),
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment).toEqual({
error: {
details: {
appName: 'Must be at most 64 characters long',
appVersion: null,
},
message: 'Invalid input',
},
ok: null,
});
});
test('create app deployment fails if app version is empty', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'myapp',
appVersion: '',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment).toEqual({
error: {
details: {
appName: null,
appVersion: 'Must be at least 1 character long',
},
message: 'Invalid input',
},
ok: null,
});
});
test('create app deployment fails if app version exceeds length of 256 characters', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: new Array(257).fill('a').join(''),
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment).toEqual({
error: {
details: {
appName: null,
appVersion: 'Must be at most 64 characters long',
},
message: 'Invalid input',
},
ok: null,
});
});
test('create app deployment fails without feature flag enabled for organization', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// When FEATURE_FLAGS_APP_DEPLOYMENTS_ENABLED=1 globally, the per-org check is bypassed
if (createAppDeployment.ok) {
expect(createAppDeployment.ok.createdAppDeployment).toBeDefined();
} else {
expect(createAppDeployment).toEqual({
error: {
details: null,
message:
'This organization has no access to app deployments. Please contact the Hive team for early access.',
},
ok: null,
});
}
});
test('add documents to app deployment fails if there is no initial schema published', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment).toEqual({
error: null,
ok: {
createdAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'pending',
},
},
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
documents: [
{
hash: 'hash',
body: 'query { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment).toEqual({
error: {
details: null,
message: 'No schema has been published yet',
},
ok: null,
});
});
test('add documents to app deployment fails if document hash is less than 1 character', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment).toEqual({
error: null,
ok: {
createdAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'pending',
},
},
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
documents: [
{
hash: '',
body: 'query { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment).toEqual({
error: {
details: {
index: 0,
message: 'Hash must be at least 1 characters long',
},
message: 'Invalid input, please check the operations.',
},
ok: null,
});
});
test('add documents to app deployment fails if document hash is longer than 256 characters', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment).toEqual({
error: null,
ok: {
createdAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'pending',
},
},
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
documents: [
{
hash: new Array(129).fill('a').join(''),
body: 'query { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment).toEqual({
error: {
details: {
index: 0,
message: 'Hash must be at most 128 characters long',
},
message: 'Invalid input, please check the operations.',
},
ok: null,
});
});
test('add documents to app deployment fails if document is not parse-able', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment).toEqual({
error: null,
ok: {
createdAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'pending',
},
},
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
documents: [
{
hash: 'hash',
body: 'qugu',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment).toEqual({
error: {
details: {
index: 0,
message: 'Syntax Error: Unexpected Name "qugu".',
},
message: 'Failed to parse a GraphQL operation.',
},
ok: null,
});
});
test('add documents to app deployment fails if document does not pass validation against the target schema', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment).toEqual({
error: null,
ok: {
createdAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'pending',
},
},
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
documents: [
{
hash: 'hash',
body: 'query { hi }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment).toEqual({
error: {
details: {
index: 0,
message: 'Cannot query field "hi" on type "Query".',
},
message: 'The GraphQL operation is not valid against the latest schema version.',
},
ok: null,
});
});
test('app deployment validates documents with MCP directives when schema does not define them', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
weather(location: String!): Weather
}
type Weather {
temp: Float
conditions: String
}
`,
});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'mcp-test',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'mcp-test',
appVersion: '1.0.0',
documents: [
{
hash: 'mcp-weather',
body: [
'query GetWeather(',
' $location: String! @mcpDescription(provider: "langfuse:loc") @mcpHeader(name: "X-Location")',
') @mcpTool(name: "get_weather", description: "Get weather") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
{
hash: 'mcp-weather-title',
body: [
'query GetWeatherTitle(',
' $location: String! @mcpDescription(provider: "langfuse:loc")',
') @mcpTool(name: "get_weather_title", title: "Weather Tool", meta: "{}") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment.error).toBeNull();
});
test('app deployment validates documents with MCP directives when schema already defines them', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
scalar JSON
directive @mcpTool(name: String!, description: String) on QUERY | MUTATION
directive @mcpDescription(provider: String!) on VARIABLE_DEFINITION | FIELD
directive @mcpHeader(name: String!) on VARIABLE_DEFINITION
type Query {
weather(location: String!): Weather
}
type Weather {
temp: Float
conditions: String
}
`,
});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'mcp-existing',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const { addDocumentsToAppDeployment: successResult } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'mcp-existing',
appVersion: '1.0.0',
documents: [
{
hash: 'mcp-weather-existing',
body: [
'query GetWeather(',
' $location: String! @mcpDescription(provider: "langfuse:loc")',
') @mcpTool(name: "get_weather", description: "Get weather") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(successResult.error).toBeNull();
const { addDocumentsToAppDeployment: failResult } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'mcp-existing',
appVersion: '1.0.0',
documents: [
{
hash: 'mcp-weather-title',
body: [
'query GetWeather(',
' $location: String! @mcpDescription(provider: "langfuse:loc")',
') @mcpTool(name: "get_weather", title: "Weather Tool") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(failResult.error).toEqual(
expect.objectContaining({
message: expect.stringContaining('not valid'),
details: expect.objectContaining({
message: expect.stringContaining('title'),
}),
}),
);
});
test('app deployment injects only missing MCP directives when schema partially defines them', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
directive @mcpTool(name: String!, description: String) on QUERY | MUTATION
type Query {
weather(location: String!): Weather
}
type Weather {
temp: Float
conditions: String
}
`,
});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'mcp-partial',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'mcp-partial',
appVersion: '1.0.0',
documents: [
{
hash: 'mcp-partial-weather',
body: [
'query GetWeather(',
' $location: String! @mcpDescription(provider: "langfuse:loc")',
') @mcpTool(name: "get_weather", description: "Get weather") {',
' weather(location: $location) { temp conditions }',
'}',
].join('\n'),
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment.error).toBeNull();
});
test('add documents to app deployment fails if document contains multiple executable operation definitions', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment).toEqual({
error: null,
ok: {
createdAppDeployment: {
id: expect.any(String),
name: 'my-app',
version: '1.0.0',
status: 'pending',
},
},
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
documents: [
{
hash: 'hash',
body: 'query a { hello } query b { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment).toEqual({
error: {
details: {
index: 0,
message:
'Multiple operation definitions found. Only one executable operation definition is allowed per document.',
},
message: 'Only one executable operation definition is allowed per document.',
},
ok: null,
});
});
test('add documents to app deployment fails if app deployment does not exist', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
documents: [
{
hash: 'hash',
body: 'query { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment).toEqual({
error: {
details: null,
message: 'App deployment not found',
},
ok: null,
});
});
test('add documents to app deployment fails without feature flag enabled for organization', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
documents: [
{
hash: 'hash',
body: 'query { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// When FEATURE_FLAGS_APP_DEPLOYMENTS_ENABLED=1 globally, the per-org check is bypassed.
expect(addDocumentsToAppDeployment.error?.message).toMatch(
/no access to app deployments|App deployment not found/,
);
expect(addDocumentsToAppDeployment.ok).toBeNull();
});
test('activate app deployment fails if app deployment does not exist', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
const { activateAppDeployment } = await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(activateAppDeployment).toEqual({
error: {
message: 'App deployment not found',
},
ok: null,
});
});
test('activate app deployment succeeds if app deployment is already active', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, target } = await createProject();
const token = await createTargetAccessToken({});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
let activateResult = await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(activateResult).toEqual({
activateAppDeployment: {
error: null,
ok: {
activatedAppDeployment: {
id: expect.any(String),
name: 'my-app',
status: 'active',
version: '1.0.0',
},
isSkipped: false,
},
},
});
activateResult = await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(activateResult).toEqual({
activateAppDeployment: {
error: null,
ok: {
activatedAppDeployment: {
id: expect.any(String),
name: 'my-app',
status: 'active',
version: '1.0.0',
},
isSkipped: true,
},
},
});
});
test('activate app deployment fails if app deployment is retired', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, target } = await createProject();
const token = await createTargetAccessToken({});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
let activateResult = await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(activateResult).toEqual({
activateAppDeployment: {
error: null,
ok: {
activatedAppDeployment: {
id: expect.any(String),
name: 'my-app',
status: 'active',
version: '1.0.0',
},
isSkipped: false,
},
},
});
const retireResult = await execute({
document: RetireAppDeployment,
variables: {
input: {
target: {
byId: target.id,
},
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(retireResult).toEqual({
retireAppDeployment: {
error: null,
ok: {
retiredAppDeployment: {
id: expect.any(String),
name: 'my-app',
status: 'retired',
version: '1.0.0',
},
},
},
});
activateResult = await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(activateResult).toEqual({
activateAppDeployment: {
error: {
message: 'App deployment is retired',
},
ok: null,
},
});
});
test('retire app deployment fails if app deployment does not exist', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, target } = await createProject();
const token = await createTargetAccessToken({});
const { retireAppDeployment } = await execute({
document: RetireAppDeployment,
variables: {
input: {
target: {
byId: target.id,
},
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(retireAppDeployment).toEqual({
error: {
message: 'App deployment not found',
},
ok: null,
});
});
test('retire app deployment fails if app deployment is pending (not active)', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, target } = await createProject();
const token = await createTargetAccessToken({});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const { retireAppDeployment } = await execute({
document: RetireAppDeployment,
variables: {
input: {
target: {
byId: target.id,
},
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(retireAppDeployment).toEqual({
error: {
message: 'App deployment is not active',
},
ok: null,
});
});
test('retire app deployment succeeds if app deployment is active', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, target } = await createProject();
const token = await createTargetAccessToken({});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const { retireAppDeployment } = await execute({
document: RetireAppDeployment,
variables: {
input: {
target: {
byId: target.id,
},
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(retireAppDeployment).toEqual({
error: null,
ok: {
retiredAppDeployment: {
id: expect.any(String),
name: 'my-app',
status: 'retired',
version: '1.0.0',
},
},
});
});
test('retire app deployments makes the persisted operations unavailable via CDN', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, createCdnAccess, target } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
});
const cdnAccess = await createCdnAccess();
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
documents: [
{
hash: 'hash',
body: 'query { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const persistedOperationUrl = `${cdnAccess.cdnUrl}/apps/my-app/1.0.0/hash`;
let response = await fetch(persistedOperationUrl, {
method: 'GET',
headers: {
'X-Hive-CDN-Key': cdnAccess.secretAccessToken,
},
});
expect(response.status).toBe(200);
await execute({
document: RetireAppDeployment,
variables: {
input: {
target: {
byId: target.id,
},
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
response = await fetch(persistedOperationUrl, {
method: 'GET',
headers: {
'X-Hive-CDN-Key': cdnAccess.secretAccessToken,
},
});
expect(response.status).toBe(404);
});
test('retire app deployments fails without feature flag enabled for organization', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { createTargetAccessToken, target } = await createProject();
const token = await createTargetAccessToken({});
const { retireAppDeployment } = await execute({
document: RetireAppDeployment,
variables: {
input: {
target: {
byId: target.id,
},
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// When FEATURE_FLAGS_APP_DEPLOYMENTS_ENABLED=1 globally, the per-org check is bypassed.
expect(retireAppDeployment.error?.message).toMatch(
/no access to app deployments|App deployment not found/,
);
expect(retireAppDeployment.ok).toBeNull();
});
test('get app deployment documents via GraphQL API', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization, createOrganizationAccessToken } =
await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target } = await createProject();
const token = await createTargetAccessToken({});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment.error).toBeNull();
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
a: String
b: String
c: String
d: String
}
`,
});
// Ensure this is possible with the minimal available permissions.
const organizationAccessToken = await createOrganizationAccessToken({
permissions: ['appDeployment:create'],
resources: {
mode: ResourceAssignmentModeType.All,
},
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
target: {
byId: target.id,
},
documents: [
{
hash: 'aaa',
body: 'query { a }',
},
{
hash: 'bbb',
body: 'query { b }',
},
{
hash: 'ccc',
body: 'query { c }',
},
{
hash: 'ddd',
body: 'query { d }',
},
],
},
},
authToken: organizationAccessToken.privateAccessKey,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment.error).toBeNull();
const result = await execute({
document: GetPaginatedPersistedDocuments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
appDeploymentName: 'app-name',
appDeploymentVersion: 'app-version',
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(result.target).toMatchObject({
appDeployment: {
documents: {
edges: [
{
cursor: 'YWFh',
node: {
body: 'query { a }',
hash: 'aaa',
},
},
{
cursor: 'YmJi',
node: {
body: 'query { b }',
hash: 'bbb',
},
},
{
cursor: 'Y2Nj',
node: {
body: 'query { c }',
hash: 'ccc',
},
},
{
cursor: 'ZGRk',
node: {
body: 'query { d }',
hash: 'ddd',
},
},
],
pageInfo: {
hasNextPage: false,
},
},
id: expect.any(String),
},
});
});
test('paginate app deployment documents via GraphQL API', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target } = await createProject();
const token = await createTargetAccessToken({});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment.error).toBeNull();
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
a: String
b: String
c: String
d: String
}
`,
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
documents: [
{
hash: 'aaa',
body: 'query { a }',
},
{
hash: 'bbb',
body: 'query { b }',
},
{
hash: 'ccc',
body: 'query { c }',
},
{
hash: 'ddd',
body: 'query { d }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment.error).toBeNull();
let result = await execute({
document: GetPaginatedPersistedDocuments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
appDeploymentName: 'app-name',
appDeploymentVersion: 'app-version',
first: 1,
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result.target).toMatchObject({
appDeployment: {
documents: {
edges: [
{
cursor: 'YWFh',
node: {
body: 'query { a }',
hash: 'aaa',
},
},
],
pageInfo: {
hasNextPage: true,
},
},
id: expect.any(String),
},
});
result = await execute({
document: GetPaginatedPersistedDocuments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
appDeploymentName: 'app-name',
appDeploymentVersion: 'app-version',
first: 1,
cursor: 'YWFh',
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result.target).toMatchObject({
appDeployment: {
documents: {
edges: [
{
cursor: 'YmJi',
node: {
body: 'query { b }',
hash: 'bbb',
},
},
],
pageInfo: {
hasNextPage: true,
},
},
id: expect.any(String),
},
});
result = await execute({
document: GetPaginatedPersistedDocuments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
appDeploymentName: 'app-name',
appDeploymentVersion: 'app-version',
cursor: 'YmJi',
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result.target).toMatchObject({
appDeployment: {
documents: {
edges: [
{
cursor: 'Y2Nj',
node: {
body: 'query { c }',
hash: 'ccc',
},
},
{
cursor: 'ZGRk',
node: {
body: 'query { d }',
hash: 'ddd',
},
},
],
pageInfo: {
hasNextPage: false,
},
},
id: expect.any(String),
},
});
});
test('app deployment usage reporting', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target, waitForOperationsCollected } =
await createProject();
const token = await createTargetAccessToken({});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment.error).toBeNull();
const sdl = /* GraphQL */ `
type Query {
a: String
b: String
c: String
d: String
}
`;
await token.publishSchema({
sdl,
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
documents: [
{
hash: 'aaa',
body: 'query { a }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment.error).toBeNull();
const { activateAppDeployment } = await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(activateAppDeployment.error).toEqual(null);
let data = await execute({
document: GetAppDeployment,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
appDeploymentName: 'app-name',
appDeploymentVersion: 'app-version',
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(data.target?.appDeployment?.lastUsed).toEqual(null);
const usageAddress = await getServiceHost('usage', 8081);
const client = createHive({
enabled: true,
token: token.secret,
usage: true,
debug: false,
agent: {
logger: createLogger('debug'),
maxSize: 1,
},
selfHosting: {
usageEndpoint: 'http://' + usageAddress,
graphqlEndpoint: 'http://noop/',
applicationUrl: 'http://noop/',
},
});
const request = new Request('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'x-graphql-client-name': 'app-name',
'x-graphql-client-version': 'app-version',
},
});
await client.collectUsage()(
{
document: parse(`query { a }`),
schema: buildASTSchema(parse(sdl)),
contextValue: { request },
},
{},
'app-name~app-version~aaa',
);
await waitForOperationsCollected(1);
data = await execute({
document: GetAppDeployment,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
appDeploymentName: 'app-name',
appDeploymentVersion: 'app-version',
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(data.target?.appDeployment?.lastUsed).toEqual(expect.any(String));
});
test('app deployment manifest is written to and accessible via CDN', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, createCdnAccess } = await createProject();
const token = await createTargetAccessToken({});
const { createAppDeployment } = await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(createAppDeployment.error).toBeNull();
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
a: String
b: String
c: String
d: String
}
`,
});
const { addDocumentsToAppDeployment } = await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
documents: [
{
hash: 'aaa',
body: 'query { a }',
},
{
hash: 'bbb',
body: 'query { b }',
},
{
hash: 'ccc',
body: 'query { c }',
},
{
hash: 'ddd',
body: 'query { d }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(addDocumentsToAppDeployment.error).toBeNull();
const cdnAccess = await createCdnAccess();
const persistedOperationUrl = `${cdnAccess.cdnUrl}/apps/app-name/app-version`;
let response = await fetch(persistedOperationUrl, {
method: 'GET',
headers: {
'X-Hive-CDN-Key': cdnAccess.secretAccessToken,
},
});
// before the app deployment is activated it shall not exist.
expect(response.status).toEqual(404);
const { activateAppDeployment } = await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(activateAppDeployment.error).toBeNull();
response = await fetch(persistedOperationUrl, {
method: 'GET',
headers: {
'X-Hive-CDN-Key': cdnAccess.secretAccessToken,
},
});
expect(response.status).toEqual(200);
let manifest = await response.json();
expect(manifest).toMatchObject({
appName: 'app-name',
appVersion: 'app-version',
documentHashes: ['aaa', 'bbb', 'ccc', 'ddd'],
id: expect.any(String),
isActive: true,
});
// Retire flow.
const { retireAppDeployment } = await execute({
document: RetireAppDeployment,
variables: {
input: {
appName: 'app-name',
appVersion: 'app-version',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(retireAppDeployment.error).toBeNull();
response = await fetch(persistedOperationUrl, {
method: 'GET',
headers: {
'X-Hive-CDN-Key': cdnAccess.secretAccessToken,
},
});
expect(response.status).toEqual(200);
manifest = await response.json();
expect(manifest).toMatchObject({
appName: 'app-name',
appVersion: 'app-version',
documentHashes: ['aaa', 'bbb', 'ccc', 'ddd'],
id: expect.any(String),
isActive: false,
});
});
test('activeAppDeployments returns empty list when no active deployments exist', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { project, target } = await createProject();
const result = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
neverUsedAndCreatedBefore: new Date().toISOString(),
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result.target?.activeAppDeployments).toEqual({
edges: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
endCursor: '',
startCursor: '',
},
});
});
test('activeAppDeployments filters by neverUsedAndCreatedBefore', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target } = await createProject();
const token = await createTargetAccessToken({});
// Create and activate an app deployment
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'unused-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'unused-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Query for deployments never used and created before tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const result = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
neverUsedAndCreatedBefore: tomorrow.toISOString(),
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result.target?.activeAppDeployments.edges).toHaveLength(1);
expect(result.target?.activeAppDeployments.edges[0].node).toMatchObject({
name: 'unused-app',
version: '1.0.0',
status: 'active',
lastUsed: null,
});
expect(result.target?.activeAppDeployments.edges[0].node.createdAt).toBeTruthy();
});
test('activeAppDeployments works for > 1000 records with a date filter (neverUsedAndCreatedBefore) set', async () => {
const seed = await initSeed();
await using conn = await seed.createDbConnection();
const { createOrg, ownerToken } = await seed.createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { project, target } = await createProject();
// seed 1,200 app deployments
const apps = ['web-app', 'mobile-app', 'admin-dashboard', 'cli-tool'];
const now = new Date().toISOString();
const appDeploymentRows = apps.flatMap((app, minor) =>
Array.from({ length: 300 }).map(
(_, patch) =>
[
target.id, // target_id
app, // name
[1, minor, patch].join('.'), // version
now, // activated_at
] as const,
),
);
// insert into postgres
const result = await conn.pool
.any(
psql`
INSERT INTO app_deployments ("target_id", "name", "version", "activated_at")
SELECT * FROM ${psql.unnest(appDeploymentRows, ['uuid', 'text', 'text', 'timestamptz'])}
RETURNING "id", "target_id", "name", "version"
`,
)
.then(
z.array(
z.object({ id: z.string(), target_id: z.string(), name: z.string(), version: z.string() }),
).parse,
);
expect(result.length).toBe(1200);
// insert into clickhouse and activate
const query = `INSERT INTO app_deployments (
"target_id"
,"app_deployment_id"
,"app_name"
,"app_version"
,"is_active"
) VALUES
${result
.map(
r => `(
'${r['target_id']}'
, '${r['id']}'
, '${r['name']}'
, '${r['version']}'
, True
)`,
)
.join(',')};`;
await clickHouseInsert(query);
// Query for deployments never used and created before tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
let cursor: string | undefined;
for (let page = 0; page < Math.ceil(1200 / 20); page++) {
const result = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
first: 20,
after: cursor,
filter: {
neverUsedAndCreatedBefore: tomorrow.toISOString(),
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
// all should be full pages
expect(result.target?.activeAppDeployments.edges).toHaveLength(20);
cursor = result.target?.activeAppDeployments.pageInfo.endCursor;
}
});
test('activeAppDeployments filters by name', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target } = await createProject();
const token = await createTargetAccessToken({});
// Create and activate multiple app deployments
for (const appName of ['frontend-app', 'backend-app', 'mobile-app']) {
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName,
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName,
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
}
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
// Query for deployments with 'front' in the name
const result = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
name: 'front',
neverUsedAndCreatedBefore: tomorrow.toISOString(),
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result.target?.activeAppDeployments.edges).toHaveLength(1);
expect(result.target?.activeAppDeployments.edges[0].node.name).toBe('frontend-app');
});
test('activeAppDeployments does not return pending or retired deployments', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target } = await createProject();
const token = await createTargetAccessToken({});
// Create a pending deployment (not activated)
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'pending-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create and activate, then retire a deployment
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'retired-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'retired-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: RetireAppDeployment,
variables: {
input: {
target: { byId: target.id },
appName: 'retired-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create and activate an active deployment
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'active-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'active-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
// Query should only return the active deployment
const result = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
neverUsedAndCreatedBefore: tomorrow.toISOString(),
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result.target?.activeAppDeployments.edges).toHaveLength(1);
expect(result.target?.activeAppDeployments.edges[0].node.name).toBe('active-app');
expect(result.target?.activeAppDeployments.edges[0].node.status).toBe('active');
});
test('activeAppDeployments filters by lastUsedBefore', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target, waitForOperationsCollected } =
await createProject();
const token = await createTargetAccessToken({});
const sdl = /* GraphQL */ `
type Query {
hello: String
}
`;
await token.publishSchema({ sdl });
// Create and activate an app deployment
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'used-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'used-app',
appVersion: '1.0.0',
documents: [
{
hash: 'hash',
body: 'query { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'used-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Report usage for this deployment
const usageAddress = await getServiceHost('usage', 8081);
const client = createHive({
enabled: true,
token: token.secret,
usage: true,
debug: false,
agent: {
logger: createLogger('debug'),
maxSize: 1,
},
selfHosting: {
usageEndpoint: 'http://' + usageAddress,
graphqlEndpoint: 'http://noop/',
applicationUrl: 'http://noop/',
},
});
const request = new Request('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'x-graphql-client-name': 'used-app',
'x-graphql-client-version': '1.0.0',
},
});
await client.collectUsage()(
{
document: parse(`query { hello }`),
schema: buildASTSchema(parse(sdl)),
contextValue: { request },
},
{},
'used-app~1.0.0~hash',
);
await waitForOperationsCollected(1);
// Query for deployments last used before tomorrow (should include our deployment)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const result = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
lastUsedBefore: tomorrow.toISOString(),
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result.target?.activeAppDeployments.edges).toHaveLength(1);
expect(result.target?.activeAppDeployments.edges[0].node).toMatchObject({
name: 'used-app',
version: '1.0.0',
status: 'active',
});
expect(result.target?.activeAppDeployments.edges[0].node.lastUsed).toBeTruthy();
// Query for deployments last used before yesterday (should NOT include our deployment)
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const result2 = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
lastUsedBefore: yesterday.toISOString(),
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result2.target?.activeAppDeployments.edges).toHaveLength(0);
});
test('activeAppDeployments applies OR logic between lastUsedBefore and neverUsedAndCreatedBefore', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target, waitForOperationsCollected } =
await createProject();
const token = await createTargetAccessToken({});
const sdl = /* GraphQL */ `
type Query {
hello: String
}
`;
await token.publishSchema({ sdl });
// Create deployment 1: will be used (matches lastUsedBefore)
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'used-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'used-app',
appVersion: '1.0.0',
documents: [{ hash: 'hash1', body: 'query { hello }' }],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'used-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create deployment 2: will never be used (matches neverUsedAndCreatedBefore)
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'unused-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'unused-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Report usage for 'used-app' only
const usageAddress = await getServiceHost('usage', 8081);
const client = createHive({
enabled: true,
token: token.secret,
usage: true,
debug: false,
agent: {
logger: createLogger('debug'),
maxSize: 1,
},
selfHosting: {
usageEndpoint: 'http://' + usageAddress,
graphqlEndpoint: 'http://noop/',
applicationUrl: 'http://noop/',
},
});
const request = new Request('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'x-graphql-client-name': 'used-app',
'x-graphql-client-version': '1.0.0',
},
});
await client.collectUsage()(
{
document: parse(`query { hello }`),
schema: buildASTSchema(parse(sdl)),
contextValue: { request },
},
{},
'used-app~1.0.0~hash1',
);
await waitForOperationsCollected(1);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const result = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
lastUsedBefore: tomorrow.toISOString(),
neverUsedAndCreatedBefore: tomorrow.toISOString(),
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
// Both deployments should match via OR logic
expect(result.target?.activeAppDeployments.edges).toHaveLength(2);
const names = result.target?.activeAppDeployments.edges.map(e => e.node.name).sort();
expect(names).toEqual(['unused-app', 'used-app']);
// Verify one has lastUsed and one doesn't
const usedApp = result.target?.activeAppDeployments.edges.find(e => e.node.name === 'used-app');
const unusedApp = result.target?.activeAppDeployments.edges.find(
e => e.node.name === 'unused-app',
);
expect(usedApp?.node.lastUsed).toBeTruthy();
expect(unusedApp?.node.lastUsed).toBeNull();
});
test('activeAppDeployments pagination with first and after', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target } = await createProject();
const token = await createTargetAccessToken({});
// Create 5 active deployments
for (let i = 1; i <= 5; i++) {
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: `app-${i}`,
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: `app-${i}`,
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
}
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
// Query with first: 2
const result1 = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
neverUsedAndCreatedBefore: tomorrow.toISOString(),
},
first: 2,
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result1.target?.activeAppDeployments.edges).toHaveLength(2);
expect(result1.target?.activeAppDeployments.pageInfo.hasNextPage).toBe(true);
expect(result1.target?.activeAppDeployments.pageInfo.endCursor).toBeTruthy();
// Query with after cursor to get next page
const endCursor = result1.target?.activeAppDeployments.pageInfo.endCursor;
const result2 = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
neverUsedAndCreatedBefore: tomorrow.toISOString(),
},
first: 2,
after: endCursor,
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result2.target?.activeAppDeployments.edges).toHaveLength(2);
expect(result2.target?.activeAppDeployments.pageInfo.hasNextPage).toBe(true);
expect(result2.target?.activeAppDeployments.pageInfo.hasPreviousPage).toBe(true);
// Get the last page
const endCursor2 = result2.target?.activeAppDeployments.pageInfo.endCursor;
const result3 = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
neverUsedAndCreatedBefore: tomorrow.toISOString(),
},
first: 2,
after: endCursor2,
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result3.target?.activeAppDeployments.edges).toHaveLength(1);
expect(result3.target?.activeAppDeployments.pageInfo.hasNextPage).toBe(false);
// Verify we got all 5 unique apps across all pages
const allNames = [
...result1.target!.activeAppDeployments.edges.map(e => e.node.name),
...result2.target!.activeAppDeployments.edges.map(e => e.node.name),
...result3.target!.activeAppDeployments.edges.map(e => e.node.name),
];
expect(allNames).toHaveLength(5);
expect(new Set(allNames).size).toBe(5);
});
test('activeAppDeployments returns error for invalid date filter', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, organization, setFeatureFlag } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { target, project } = await createProject();
// DateTime scalar rejects invalid date strings at the GraphQL level
const result = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
lastUsedBefore: 'not-a-valid-date',
},
},
authToken: ownerToken,
});
expect(result.rawBody.errors).toBeDefined();
expect(result.rawBody.errors?.[0]?.message).toMatch(/DateTime|Invalid|date/i);
});
test('activeAppDeployments filters by name combined with lastUsedBefore', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target, waitForOperationsCollected } =
await createProject();
const token = await createTargetAccessToken({});
const sdl = /* GraphQL */ `
type Query {
hello: String
}
`;
await token.publishSchema({ sdl });
// Create frontend-app
await execute({
document: CreateAppDeployment,
variables: { input: { appName: 'frontend-app', appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'frontend-app',
appVersion: '1.0.0',
documents: [{ hash: 'hash1', body: 'query { hello }' }],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: { input: { appName: 'frontend-app', appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create backend-app
await execute({
document: CreateAppDeployment,
variables: { input: { appName: 'backend-app', appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'backend-app',
appVersion: '1.0.0',
documents: [{ hash: 'hash2', body: 'query { hello }' }],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: { input: { appName: 'backend-app', appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Report usage for frontend-app only
const usageAddress = await getServiceHost('usage', 8081);
const client = createHive({
enabled: true,
token: token.secret,
usage: true,
debug: false,
agent: {
logger: createLogger('debug'),
maxSize: 1,
},
selfHosting: {
usageEndpoint: 'http://' + usageAddress,
graphqlEndpoint: 'http://noop/',
applicationUrl: 'http://noop/',
},
});
const request = new Request('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'x-graphql-client-name': 'frontend-app',
'x-graphql-client-version': '1.0.0',
},
});
await client.collectUsage()(
{
document: parse(`query { hello }`),
schema: buildASTSchema(parse(sdl)),
contextValue: { request },
},
{},
'frontend-app~1.0.0~hash1',
);
await waitForOperationsCollected(1);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
// Filter by name "frontend" AND lastUsedBefore tomorrow should get frontend-app
const result = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
name: 'frontend',
lastUsedBefore: tomorrow.toISOString(),
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result.target?.activeAppDeployments.edges).toHaveLength(1);
expect(result.target?.activeAppDeployments.edges[0]?.node.name).toBe('frontend-app');
});
test('activeAppDeployments check pagination clamp', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target } = await createProject();
const token = await createTargetAccessToken({});
await token.publishSchema({
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
});
// Create 25 active app deployments
for (let i = 0; i < 25; i++) {
const appName = `app-${i.toString().padStart(2, '0')}`;
await execute({
document: CreateAppDeployment,
variables: { input: { appName, appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName,
appVersion: '1.0.0',
documents: [{ hash: `hash-${i}`, body: 'query { hello }' }],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: { input: { appName, appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
}
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
// Request 100 items, should only get 20 (max limit)
const result = await execute({
document: GetActiveAppDeployments,
variables: {
targetSelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
filter: {
neverUsedAndCreatedBefore: tomorrow.toISOString(),
},
first: 100,
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
// Should be clamped to 20
expect(result.target?.activeAppDeployments.edges).toHaveLength(20);
expect(result.target?.activeAppDeployments.pageInfo.hasNextPage).toBe(true);
});
const SchemaCheckWithAffectedAppDeployments = graphql(`
query SchemaCheckWithAffectedAppDeployments(
$organizationSlug: String!
$projectSlug: String!
$targetSlug: String!
$schemaCheckId: ID!
) {
target(
reference: {
bySelector: {
organizationSlug: $organizationSlug
projectSlug: $projectSlug
targetSlug: $targetSlug
}
}
) {
schemaCheck(id: $schemaCheckId) {
id
breakingSchemaChanges {
edges {
node {
message
path
isSafeBasedOnUsage
affectedAppDeployments {
edges {
cursor
node {
id
name
version
affectedOperations {
edges {
cursor
node {
hash
name
}
}
}
}
}
totalCount
}
}
}
}
}
}
}
`);
test('schema check shows affected app deployments for breaking changes', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { project, target, createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
const publishResult = await execute({
document: graphql(`
mutation PublishSchemaForAffectedAppDeployments($input: SchemaPublishInput!) {
schemaPublish(input: $input) {
__typename
... on SchemaPublishSuccess {
valid
}
... on SchemaPublishError {
valid
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
hello: String
world: String
}
`,
author: 'test-author',
commit: 'test-commit',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'test-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'test-app',
appVersion: '1.0.0',
documents: [
{
hash: 'hello-query-hash',
body: 'query GetHello { hello }',
},
{
hash: 'world-query-hash',
body: 'query GetWorld { world }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'test-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let schemaCheckData: any = null;
// ClickHouse eventual consistency
await pollFor(
async () => {
const checkResult = await execute({
document: graphql(`
mutation SchemaCheckForAffectedAppDeploymentsPoll($input: SchemaCheckInput!) {
schemaCheck(input: $input) {
__typename
... on SchemaCheckSuccess {
schemaCheck {
id
}
}
... on SchemaCheckError {
schemaCheck {
id
}
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
world: String
}
`,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') {
return false;
}
const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id;
if (!schemaCheckId) {
return false;
}
schemaCheckData = await execute({
document: SchemaCheckWithAffectedAppDeployments,
variables: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
schemaCheckId,
},
authToken: ownerToken,
});
const breakingChanges =
schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
// Check if the hello field removal has affectedAppDeployments
const helloFieldRemoval = breakingChanges?.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
return !!(helloFieldRemoval?.node.affectedAppDeployments?.edges?.length ?? 0);
},
{ maxWait: 15_000 },
);
const breakingChanges =
schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
// console.log('breakingChanges:', JSON.stringify(breakingChanges, null, 2));
expect(breakingChanges).toBeDefined();
expect(breakingChanges!.length).toBeGreaterThan(0);
const helloFieldRemoval = breakingChanges!.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
// console.log('helloFieldRemoval:', JSON.stringify(helloFieldRemoval, null, 2));
expect(helloFieldRemoval).toBeDefined();
expect(helloFieldRemoval?.node.affectedAppDeployments?.edges).toBeDefined();
expect(helloFieldRemoval?.node.affectedAppDeployments?.edges?.length).toBe(1);
const affectedDeployment = helloFieldRemoval?.node.affectedAppDeployments?.edges?.[0]?.node;
expect(affectedDeployment?.name).toBe('test-app');
expect(affectedDeployment?.version).toBe('1.0.0');
expect(affectedDeployment?.affectedOperations.edges).toBeDefined();
expect(affectedDeployment?.affectedOperations.edges.length).toBe(1);
expect(affectedDeployment?.affectedOperations.edges[0].node.hash).toBe('hello-query-hash');
expect(affectedDeployment?.affectedOperations.edges[0].node.name).toBe('GetHello');
});
test('breaking changes show only deployments affected by their specific coordinate', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { project, target, createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
const publishResult = await execute({
document: graphql(`
mutation PublishSchemaForCoordinateTest($input: SchemaPublishInput!) {
schemaPublish(input: $input) {
__typename
... on SchemaPublishSuccess {
valid
}
... on SchemaPublishError {
valid
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
hello: String
world: String
foo: String
}
`,
author: 'test-author',
commit: 'test-commit',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'app-a',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'app-a',
appVersion: '1.0.0',
documents: [
{
hash: 'app-a-hello-hash',
body: 'query AppAHello { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'app-a',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'app-b',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'app-b',
appVersion: '1.0.0',
documents: [
{
hash: 'app-b-world-hash',
body: 'query AppBWorld { world }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'app-b',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let schemaCheckData: any = null;
await pollFor(
async () => {
const checkResult = await execute({
document: graphql(`
mutation SchemaCheckForCoordinateTestPoll($input: SchemaCheckInput!) {
schemaCheck(input: $input) {
__typename
... on SchemaCheckSuccess {
schemaCheck {
id
}
}
... on SchemaCheckError {
schemaCheck {
id
}
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
foo: String
}
`,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') {
return false;
}
const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id;
if (!schemaCheckId) {
return false;
}
schemaCheckData = await execute({
document: SchemaCheckWithAffectedAppDeployments,
variables: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
schemaCheckId,
},
authToken: ownerToken,
});
const breakingChanges =
schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
// Check if both breaking changes have affectedAppDeployments
const helloRemoval = breakingChanges?.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
const worldRemoval = breakingChanges?.find((edge: { node: { message: string } }) =>
edge.node.message.includes('world'),
);
return !!(
(helloRemoval?.node.affectedAppDeployments?.edges?.length ?? 0) &&
(worldRemoval?.node.affectedAppDeployments?.edges?.length ?? 0)
);
},
{ maxWait: 15_000 },
);
const breakingChanges =
schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
expect(breakingChanges).toBeDefined();
expect(breakingChanges!.length).toBe(2);
const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
const worldRemoval = breakingChanges!.find((edge: { node: { message: string } }) =>
edge.node.message.includes('world'),
);
// Verify hello removal only shows App A (not App B)
expect(helloRemoval).toBeDefined();
expect(helloRemoval?.node.affectedAppDeployments?.edges?.length).toBe(1);
expect(helloRemoval?.node.affectedAppDeployments?.edges?.[0]?.node.name).toBe('app-a');
expect(
helloRemoval?.node.affectedAppDeployments?.edges?.[0]?.node.affectedOperations.edges.length,
).toBe(1);
expect(
helloRemoval?.node.affectedAppDeployments?.edges?.[0]?.node.affectedOperations.edges[0].node
.hash,
).toBe('app-a-hello-hash');
// Verify world removal only shows App B (not App A)
expect(worldRemoval).toBeDefined();
expect(worldRemoval?.node.affectedAppDeployments?.edges?.length).toBe(1);
expect(worldRemoval?.node.affectedAppDeployments?.edges?.[0]?.node.name).toBe('app-b');
expect(
worldRemoval?.node.affectedAppDeployments?.edges?.[0]?.node.affectedOperations.edges.length,
).toBe(1);
expect(
worldRemoval?.node.affectedAppDeployments?.edges?.[0]?.node.affectedOperations.edges[0].node
.hash,
).toBe('app-b-world-hash');
});
test('retired app deployments are excluded from affected deployments', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { project, target, createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
// Publish schema
await execute({
document: graphql(`
mutation PublishSchemaForRetiredTest($input: SchemaPublishInput!) {
schemaPublish(input: $input) {
__typename
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
author: 'test-author',
commit: 'test-commit',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create and activate app deployment
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'retired-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'retired-app',
appVersion: '1.0.0',
documents: [
{
hash: 'retired-app-hash',
body: 'query GetHello { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'retired-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Retire the app deployment
await execute({
document: RetireAppDeployment,
variables: {
input: {
appName: 'retired-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let schemaCheckData: any = null;
// Schema check that removes hello field - retired deployment should NOT appear
await pollFor(
async () => {
const checkResult = await execute({
document: graphql(`
mutation SchemaCheckForRetiredTestPoll($input: SchemaCheckInput!) {
schemaCheck(input: $input) {
__typename
... on SchemaCheckError {
schemaCheck {
id
}
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
world: String
}
`,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') {
return false;
}
const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id;
if (!schemaCheckId) {
return false;
}
schemaCheckData = await execute({
document: SchemaCheckWithAffectedAppDeployments,
variables: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
schemaCheckId,
},
authToken: ownerToken,
});
return true;
},
{ maxWait: 15_000 },
);
const breakingChanges =
schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
expect(breakingChanges).toBeDefined();
expect(breakingChanges!.length).toBeGreaterThan(0);
const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
// Retired deployment should NOT appear in affected deployments
expect(helloRemoval).toBeDefined();
expect(helloRemoval?.node.affectedAppDeployments?.edges?.length).toBe(0);
});
test('pending (non-activated) app deployments are excluded from affected deployments', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { project, target, createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
// Publish schema
await execute({
document: graphql(`
mutation PublishSchemaForPendingTest($input: SchemaPublishInput!) {
schemaPublish(input: $input) {
__typename
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
author: 'test-author',
commit: 'test-commit',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create app deployment but DO NOT activate it
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'pending-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'pending-app',
appVersion: '1.0.0',
documents: [
{
hash: 'pending-app-hash',
body: 'query GetHello { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Note: NOT activating the deployment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let schemaCheckData: any = null;
// Schema check that removes hello field - pending deployment should NOT appear
await pollFor(
async () => {
const checkResult = await execute({
document: graphql(`
mutation SchemaCheckForPendingTestPoll($input: SchemaCheckInput!) {
schemaCheck(input: $input) {
__typename
... on SchemaCheckError {
schemaCheck {
id
}
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
world: String
}
`,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') {
return false;
}
const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id;
if (!schemaCheckId) {
return false;
}
schemaCheckData = await execute({
document: SchemaCheckWithAffectedAppDeployments,
variables: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
schemaCheckId,
},
authToken: ownerToken,
});
return true;
},
{ maxWait: 15_000 },
);
const breakingChanges =
schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
expect(breakingChanges).toBeDefined();
expect(breakingChanges!.length).toBeGreaterThan(0);
const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
// Pending (non-activated) deployment should NOT appear in affected deployments
expect(helloRemoval).toBeDefined();
expect(helloRemoval?.node.affectedAppDeployments?.edges?.length).toBe(0);
});
test('multiple deployments affected by same breaking change all appear', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { project, target, createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await execute({
document: graphql(`
mutation PublishSchemaForMultiDeploymentTest($input: SchemaPublishInput!) {
schemaPublish(input: $input) {
__typename
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
author: 'test-author',
commit: 'test-commit',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create and activate App 1 - uses hello field
await execute({
document: CreateAppDeployment,
variables: { input: { appName: 'multi-app-1', appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'multi-app-1',
appVersion: '1.0.0',
documents: [{ hash: 'multi-app-1-hash', body: 'query App1Hello { hello }' }],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: { input: { appName: 'multi-app-1', appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create and activate App 2 - also uses hello field
await execute({
document: CreateAppDeployment,
variables: { input: { appName: 'multi-app-2', appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'multi-app-2',
appVersion: '1.0.0',
documents: [{ hash: 'multi-app-2-hash', body: 'query App2Hello { hello }' }],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: { input: { appName: 'multi-app-2', appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let schemaCheckData: any = null;
await pollFor(
async () => {
const checkResult = await execute({
document: graphql(`
mutation SchemaCheckForMultiDeploymentTestPoll($input: SchemaCheckInput!) {
schemaCheck(input: $input) {
__typename
... on SchemaCheckError {
schemaCheck {
id
}
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
world: String
}
`,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') {
return false;
}
const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id;
if (!schemaCheckId) {
return false;
}
schemaCheckData = await execute({
document: SchemaCheckWithAffectedAppDeployments,
variables: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
schemaCheckId,
},
authToken: ownerToken,
});
const breakingChanges =
schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
const helloRemoval = breakingChanges?.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
// Wait until both deployments appear
return (helloRemoval?.node.affectedAppDeployments?.edges?.length ?? 0) >= 2;
},
{ maxWait: 15_000 },
);
const breakingChanges =
schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
// Both deployments should appear
expect(helloRemoval?.node.affectedAppDeployments?.edges?.length).toBe(2);
const appNames = helloRemoval?.node.affectedAppDeployments?.edges?.map(
(e: { node: { name: string } }) => e.node.name,
);
expect(appNames).toContain('multi-app-1');
expect(appNames).toContain('multi-app-2');
});
test('anonymous operations (null name) are handled correctly', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { project, target, createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await execute({
document: graphql(`
mutation PublishSchemaForAnonOpTest($input: SchemaPublishInput!) {
schemaPublish(input: $input) {
__typename
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
author: 'test-author',
commit: 'test-commit',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create app with anonymous operation (no operation name)
await execute({
document: CreateAppDeployment,
variables: { input: { appName: 'anon-app', appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'anon-app',
appVersion: '1.0.0',
documents: [{ hash: 'anon-op-hash', body: '{ hello }' }], // Anonymous query
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: { input: { appName: 'anon-app', appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let schemaCheckData: any = null;
await pollFor(
async () => {
const checkResult = await execute({
document: graphql(`
mutation SchemaCheckForAnonOpTestPoll($input: SchemaCheckInput!) {
schemaCheck(input: $input) {
__typename
... on SchemaCheckError {
schemaCheck {
id
}
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
world: String
}
`,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') {
return false;
}
const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id;
if (!schemaCheckId) {
return false;
}
schemaCheckData = await execute({
document: SchemaCheckWithAffectedAppDeployments,
variables: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
schemaCheckId,
},
authToken: ownerToken,
});
const breakingChanges =
schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
const helloRemoval = breakingChanges?.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
return !!(helloRemoval?.node.affectedAppDeployments?.edges?.length ?? 0);
},
{ maxWait: 15_000 },
);
const breakingChanges =
schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
expect(helloRemoval?.node.affectedAppDeployments?.edges?.length).toBe(1);
const affectedOp =
helloRemoval?.node.affectedAppDeployments?.edges?.[0]?.node.affectedOperations.edges[0].node;
expect(affectedOp.hash).toBe('anon-op-hash');
expect(affectedOp.name).toBeNull(); // Anonymous operation has null name
});
test('multiple operations in same deployment affected by same change', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { project, target, createTargetAccessToken } = await createProject();
const token = await createTargetAccessToken({});
await execute({
document: graphql(`
mutation PublishSchemaForMultiOpTest($input: SchemaPublishInput!) {
schemaPublish(input: $input) {
__typename
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
author: 'test-author',
commit: 'test-commit',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create app with multiple operations using hello field
await execute({
document: CreateAppDeployment,
variables: { input: { appName: 'multi-op-app', appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'multi-op-app',
appVersion: '1.0.0',
documents: [
{ hash: 'op-1-hash', body: 'query GetHello1 { hello }' },
{ hash: 'op-2-hash', body: 'query GetHello2 { hello }' },
{ hash: 'op-3-hash', body: 'query GetHello3 { hello }' },
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: { input: { appName: 'multi-op-app', appVersion: '1.0.0' } },
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let schemaCheckData: any = null;
await pollFor(
async () => {
const checkResult = await execute({
document: graphql(`
mutation SchemaCheckForMultiOpTestPoll($input: SchemaCheckInput!) {
schemaCheck(input: $input) {
__typename
... on SchemaCheckError {
schemaCheck {
id
}
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
world: String
}
`,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') {
return false;
}
const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id;
if (!schemaCheckId) {
return false;
}
schemaCheckData = await execute({
document: SchemaCheckWithAffectedAppDeployments,
variables: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
schemaCheckId,
},
authToken: ownerToken,
});
const breakingChanges =
schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
const helloRemoval = breakingChanges?.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
// Wait until all 3 operations appear
return (
(helloRemoval?.node.affectedAppDeployments?.edges?.[0]?.node?.affectedOperations?.edges
?.length ?? 0) >= 3
);
},
{ maxWait: 15_000 },
);
const breakingChanges =
schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
expect(helloRemoval?.node.affectedAppDeployments?.edges?.length).toBe(1);
const affectedOps =
helloRemoval?.node.affectedAppDeployments?.edges?.[0]?.node.affectedOperations.edges;
expect(affectedOps.length).toBe(3);
const opHashes = affectedOps.map((e: { node: { hash: string } }) => e.node.hash);
expect(opHashes).toContain('op-1-hash');
expect(opHashes).toContain('op-2-hash');
expect(opHashes).toContain('op-3-hash');
});
test('schema check fails if breaking change affects app deployment even when usage data says safe', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const {
project,
target,
createTargetAccessToken,
updateTargetValidationSettings,
waitForOperationsCollected,
} = await createProject();
const token = await createTargetAccessToken({});
const sdl = /* GraphQL */ `
type Query {
hello: String
world: String
}
`;
await token.publishSchema({ sdl });
const usageReport = await token.collectUsage({
size: 1,
map: {
'world-op': {
operationName: 'GetWorld',
operation: 'query GetWorld { world }',
fields: ['Query', 'Query.world'],
},
},
operations: [
{
operationMapKey: 'world-op',
timestamp: Date.now(),
execution: {
ok: true,
duration: 100000000,
errorsTotal: 0,
},
metadata: {
client: {
name: 'demo',
version: '0.0.1',
},
},
},
],
});
expect(usageReport.status).toBe(200);
await waitForOperationsCollected(1);
await updateTargetValidationSettings({
isEnabled: true,
percentage: 0,
});
const baselineCheck = await execute({
document: graphql(`
mutation BaselineSchemaCheck($input: SchemaCheckInput!) {
schemaCheck(input: $input) {
__typename
... on SchemaCheckSuccess {
valid
schemaCheck {
id
}
}
... on SchemaCheckError {
valid
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
world: String
}
`,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(baselineCheck.schemaCheck.__typename).toBe('SchemaCheckSuccess');
expect(baselineCheck.schemaCheck).toMatchObject({
__typename: 'SchemaCheckSuccess',
valid: true,
});
const baselineSchemaCheckId = (baselineCheck.schemaCheck as { schemaCheck: { id: string } })
.schemaCheck.id;
const baselineDetails = await execute({
document: SchemaCheckWithAffectedAppDeployments,
variables: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
schemaCheckId: baselineSchemaCheckId,
},
authToken: ownerToken,
});
const baselineBreakingChanges =
baselineDetails.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
const baselineHelloRemoval = baselineBreakingChanges?.find(
(edge: { node: { message: string } }) => edge.node.message.includes('hello'),
);
expect(baselineHelloRemoval?.node.isSafeBasedOnUsage).toBe(true);
expect(baselineHelloRemoval?.node.affectedAppDeployments?.edges?.length).toBe(0);
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '2.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '2.0.0',
documents: [
{
hash: 'hello-query-hash',
body: 'query GetHello { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '2.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
let schemaCheckData: any = null;
await pollFor(
async () => {
const checkResult = await execute({
document: graphql(`
mutation SchemaCheckWithAppDeploymentOverride($input: SchemaCheckInput!) {
schemaCheck(input: $input) {
__typename
... on SchemaCheckSuccess {
schemaCheck {
id
}
}
... on SchemaCheckError {
schemaCheck {
id
}
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
world: String
}
`,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') {
return false;
}
const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id;
if (!schemaCheckId) {
return false;
}
schemaCheckData = await execute({
document: SchemaCheckWithAffectedAppDeployments,
variables: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
schemaCheckId,
},
authToken: ownerToken,
});
const breakingChanges =
schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
const helloFieldRemoval = breakingChanges?.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
return !!(helloFieldRemoval?.node.affectedAppDeployments?.edges?.length ?? 0);
},
{ maxWait: 15_000 },
);
const breakingChanges =
schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
expect(breakingChanges).toBeDefined();
expect(breakingChanges!.length).toBeGreaterThan(0);
const helloFieldRemoval = breakingChanges!.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
expect(helloFieldRemoval).toBeDefined();
// The change should NOT be marked as safe because app deployment uses it
expect(helloFieldRemoval?.node.isSafeBasedOnUsage).toBe(false);
expect(helloFieldRemoval?.node.affectedAppDeployments?.edges).toBeDefined();
expect(helloFieldRemoval?.node.affectedAppDeployments?.edges?.length).toBe(1);
const affectedDeployment = helloFieldRemoval?.node.affectedAppDeployments?.edges?.[0]?.node;
expect(affectedDeployment?.name).toBe('my-app');
expect(affectedDeployment?.version).toBe('2.0.0');
expect(affectedDeployment?.affectedOperations.edges).toBeDefined();
expect(affectedDeployment?.affectedOperations.edges.length).toBe(1);
expect(affectedDeployment?.affectedOperations.edges[0].node.hash).toBe('hello-query-hash');
});
test('fields NOT used by app deployment remain safe based on usage', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { project, target, createTargetAccessToken, updateTargetValidationSettings } =
await createProject();
const token = await createTargetAccessToken({});
const sdl = /* GraphQL */ `
type Query {
hello: String
world: String
unused: String
}
`;
await token.publishSchema({ sdl });
await updateTargetValidationSettings({
isEnabled: true,
percentage: 0,
});
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
documents: [
{
hash: 'hello-query-hash',
body: 'query HelloQuery { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let schemaCheckData: any = null;
await pollFor(
async () => {
const checkResult = await execute({
document: graphql(`
mutation InverseTestSchemaCheck($input: SchemaCheckInput!) {
schemaCheck(input: $input) {
__typename
... on SchemaCheckSuccess {
schemaCheck {
id
}
}
... on SchemaCheckError {
schemaCheck {
id
}
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
world: String
}
`,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') {
return false;
}
const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id;
if (!schemaCheckId) {
return false;
}
schemaCheckData = await execute({
document: SchemaCheckWithAffectedAppDeployments,
variables: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
schemaCheckId,
},
authToken: ownerToken,
});
const breakingChanges =
schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
const helloRemoval = breakingChanges?.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
return !!(helloRemoval?.node.affectedAppDeployments?.edges?.length ?? 0);
},
{ maxWait: 15_000 },
);
const breakingChanges =
schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
expect(breakingChanges).toBeDefined();
expect(breakingChanges!.length).toBe(2); // hello and unused
const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
const unusedRemoval = breakingChanges!.find((edge: { node: { message: string } }) =>
edge.node.message.includes('unused'),
);
// 'hello' should be UNSAFE because app deployment uses it
expect(helloRemoval).toBeDefined();
expect(helloRemoval?.node.isSafeBasedOnUsage).toBe(false);
expect(helloRemoval?.node.affectedAppDeployments?.edges?.length).toBe(1);
expect(unusedRemoval).toBeDefined();
expect(unusedRemoval?.node.isSafeBasedOnUsage).toBe(true);
expect(unusedRemoval?.node.affectedAppDeployments?.edges?.length).toBe(0);
});
test('excludedAppDeployments filters out matching app deployments from affected list', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target, toggleTargetValidation } =
await createProject();
const token = await createTargetAccessToken({});
await toggleTargetValidation(true);
// Publish initial schema
await execute({
document: graphql(`
mutation PublishSchemaForExcludedAppDeployments($input: SchemaPublishInput!) {
schemaPublish(input: $input) {
__typename
... on SchemaPublishSuccess {
valid
}
... on SchemaPublishError {
valid
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
hello: String
world: String
}
`,
author: 'test-author',
commit: 'test-commit',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create first app deployment (will be excluded)
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'excluded-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'excluded-app',
appVersion: '1.0.0',
documents: [
{
hash: 'excluded-app-hello-hash',
body: 'query ExcludedHello { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'excluded-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create second app deployment (will NOT be excluded)
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'included-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'included-app',
appVersion: '1.0.0',
documents: [
{
hash: 'included-app-hello-hash',
body: 'query IncludedHello { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'included-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Configure conditional breaking changes with excludedAppDeployments
await execute({
document: graphql(`
mutation UpdateTargetValidationForExcludedAppDeployments(
$input: UpdateTargetConditionalBreakingChangeConfigurationInput!
) {
updateTargetConditionalBreakingChangeConfiguration(input: $input) {
ok {
target {
id
conditionalBreakingChangeConfiguration {
excludedAppDeployments
}
}
}
error {
message
}
}
}
`),
variables: {
input: {
target: {
bySelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
conditionalBreakingChangeConfiguration: {
isEnabled: true,
percentage: 0,
period: 2,
targetIds: [target.id],
excludedAppDeployments: ['excluded-app'],
},
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let schemaCheckData: any = null;
// Run schema check that removes hello field (both apps use it)
await pollFor(
async () => {
const checkResult = await execute({
document: graphql(`
mutation SchemaCheckForExcludedAppDeployments($input: SchemaCheckInput!) {
schemaCheck(input: $input) {
__typename
... on SchemaCheckSuccess {
schemaCheck {
id
}
}
... on SchemaCheckError {
schemaCheck {
id
}
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
world: String
}
`,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') {
return false;
}
const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id;
if (!schemaCheckId) {
return false;
}
schemaCheckData = await execute({
document: SchemaCheckWithAffectedAppDeployments,
variables: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
schemaCheckId,
},
authToken: ownerToken,
});
const breakingChanges =
schemaCheckData.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
const helloRemoval = breakingChanges?.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
// Wait until affectedAppDeployments is populated
return !!(helloRemoval?.node.affectedAppDeployments?.edges?.length ?? 0);
},
{ maxWait: 15_000 },
);
const breakingChanges =
schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
const helloRemoval = breakingChanges!.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
// Only included-app should appear (excluded-app should be filtered out)
expect(helloRemoval?.node.affectedAppDeployments?.edges?.length).toBe(1);
expect(helloRemoval?.node.affectedAppDeployments?.edges?.[0]?.node.name).toBe('included-app');
// Breaking change is still breaking since included-app uses the field
expect(helloRemoval?.node.isSafeBasedOnUsage).toBe(false);
});
test('excludedAppDeployments returns empty list when all affected apps are excluded', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target, toggleTargetValidation } =
await createProject();
const token = await createTargetAccessToken({});
await toggleTargetValidation(true);
// Publish initial schema
await execute({
document: graphql(`
mutation PublishSchemaForFullExclusion($input: SchemaPublishInput!) {
schemaPublish(input: $input) {
__typename
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
hello: String
}
`,
author: 'test-author',
commit: 'test-commit',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Create single app deployment
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'only-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'only-app',
appVersion: '1.0.0',
documents: [
{
hash: 'only-app-hello-hash',
body: 'query OnlyHello { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'only-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Exclude the only app deployment
await execute({
document: graphql(`
mutation UpdateTargetValidationForFullExclusion(
$input: UpdateTargetConditionalBreakingChangeConfigurationInput!
) {
updateTargetConditionalBreakingChangeConfiguration(input: $input) {
ok {
target {
id
conditionalBreakingChangeConfiguration {
excludedAppDeployments
}
}
}
error {
message
}
}
}
`),
variables: {
input: {
target: {
bySelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
conditionalBreakingChangeConfiguration: {
isEnabled: true,
percentage: 0,
period: 2,
targetIds: [target.id],
excludedAppDeployments: ['only-app'],
},
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let schemaCheckData: any = null;
// Run schema check that removes hello field
await pollFor(
async () => {
const checkResult = await execute({
document: graphql(`
mutation SchemaCheckForFullExclusion($input: SchemaCheckInput!) {
schemaCheck(input: $input) {
__typename
... on SchemaCheckSuccess {
schemaCheck {
id
}
}
... on SchemaCheckError {
schemaCheck {
id
}
}
}
}
`),
variables: {
input: {
sdl: /* GraphQL */ `
type Query {
foo: String
}
`,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// When all deployments are excluded, the check may succeed (safe based on usage)
const schemaCheckId =
checkResult.schemaCheck.__typename === 'SchemaCheckError'
? checkResult.schemaCheck.schemaCheck?.id
: checkResult.schemaCheck.__typename === 'SchemaCheckSuccess'
? checkResult.schemaCheck.schemaCheck?.id
: null;
if (!schemaCheckId) {
return false;
}
schemaCheckData = await execute({
document: SchemaCheckWithAffectedAppDeployments,
variables: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
schemaCheckId,
},
authToken: ownerToken,
});
// Return true once we have the schema check data
return !!schemaCheckData.rawBody.data?.target?.schemaCheck;
},
{ maxWait: 15_000 },
);
const breakingChanges =
schemaCheckData!.rawBody.data?.target?.schemaCheck?.breakingSchemaChanges?.edges;
const helloRemoval = breakingChanges?.find((edge: { node: { message: string } }) =>
edge.node.message.includes('hello'),
);
// When all affected app deployments are excluded, the list should be empty (or no breaking change at all)
expect(helloRemoval?.node.affectedAppDeployments?.edges?.length ?? 0).toBe(0);
});
test('update app deployment protection configuration', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { project, target } = await createProject();
// Query initial state
let result = await execute({
document: GetTargetAppDeploymentProtectionConfiguration,
variables: {
selector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result.target?.appDeploymentProtectionConfiguration).toEqual({
isEnabled: false,
minDaysInactive: 7,
minDaysSinceCreation: 7,
maxTrafficPercentage: 1.0,
trafficPeriodDays: 30,
ruleLogic: 'AND',
});
// Enable protection with custom settings
const updateResult = await execute({
document: UpdateTargetAppDeploymentProtectionConfiguration,
variables: {
input: {
target: {
bySelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
appDeploymentProtectionConfiguration: {
isEnabled: true,
minDaysInactive: 7,
maxTrafficPercentage: 5.0,
},
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(updateResult.updateTargetAppDeploymentProtectionConfiguration).toEqual({
ok: {
target: {
id: expect.any(String),
appDeploymentProtectionConfiguration: {
isEnabled: true,
minDaysInactive: 7,
minDaysSinceCreation: 7,
maxTrafficPercentage: 5.0,
trafficPeriodDays: 30,
ruleLogic: 'AND',
},
},
},
error: null,
});
// Query updated state
result = await execute({
document: GetTargetAppDeploymentProtectionConfiguration,
variables: {
selector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
expect(result.target?.appDeploymentProtectionConfiguration).toEqual({
isEnabled: true,
minDaysInactive: 7,
minDaysSinceCreation: 7,
maxTrafficPercentage: 5.0,
trafficPeriodDays: 30,
ruleLogic: 'AND',
});
});
test('retire app deployment succeeds when protection is enabled but no usage data exists', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target } = await createProject();
const token = await createTargetAccessToken({});
// Enable protection
await execute({
document: UpdateTargetAppDeploymentProtectionConfiguration,
variables: {
input: {
target: {
bySelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
appDeploymentProtectionConfiguration: {
isEnabled: true,
minDaysInactive: 7,
minDaysSinceCreation: 0,
maxTrafficPercentage: 1.0,
},
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
// Create and activate app deployment
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Retire should succeed because minDaysSinceCreation=0 and no usage data exists
const { retireAppDeployment } = await execute({
document: RetireAppDeployment,
variables: {
input: {
target: {
byId: target.id,
},
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(retireAppDeployment).toEqual({
error: null,
ok: {
retiredAppDeployment: {
id: expect.any(String),
name: 'my-app',
status: 'retired',
version: '1.0.0',
},
},
});
});
test('retire app deployment blocked when created less than minDaysSinceCreation days ago', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target } = await createProject();
const token = await createTargetAccessToken({});
// Enable protection with minDaysSinceCreation=7 (newly created deployments blocked)
await execute({
document: UpdateTargetAppDeploymentProtectionConfiguration,
variables: {
input: {
target: {
bySelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
appDeploymentProtectionConfiguration: {
isEnabled: true,
minDaysSinceCreation: 7,
minDaysInactive: 0,
maxTrafficPercentage: 100,
},
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
// Create and activate app deployment (created just now)
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Retire should fail because deployment was created 0 days ago but requires 7
const { retireAppDeployment } = await execute({
document: RetireAppDeployment,
variables: {
input: {
target: {
byId: target.id,
},
appName: 'my-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(retireAppDeployment).toEqual({
error: {
message: expect.stringContaining('requires at least 7 days since creation'),
},
ok: null,
});
});
test('retire app deployment with --force bypasses protection', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, setFeatureFlag, organization } = await createOrg();
await setFeatureFlag('appDeployments', true);
const { createTargetAccessToken, project, target, waitForOperationsCollected } =
await createProject();
const token = await createTargetAccessToken({});
const sdl = /* GraphQL */ `
type Query {
hello: String
}
`;
await token.publishSchema({ sdl });
// Enable protection with strict settings
await execute({
document: UpdateTargetAppDeploymentProtectionConfiguration,
variables: {
input: {
target: {
bySelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
appDeploymentProtectionConfiguration: {
isEnabled: true,
minDaysInactive: 365, // Very strict - 1 year
maxTrafficPercentage: 0, // No traffic allowed
},
},
},
authToken: ownerToken,
}).then(res => res.expectNoGraphQLErrors());
// Create and activate app deployment
await execute({
document: CreateAppDeployment,
variables: {
input: {
appName: 'force-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: AddDocumentsToAppDeployment,
variables: {
input: {
appName: 'force-app',
appVersion: '1.0.0',
documents: [
{
hash: 'hash',
body: 'query { hello }',
},
],
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
await execute({
document: ActivateAppDeployment,
variables: {
input: {
appName: 'force-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
// Report usage to trigger protection
const usageAddress = await getServiceHost('usage', 8081);
const client = createHive({
enabled: true,
token: token.secret,
usage: true,
debug: false,
agent: {
logger: createLogger('debug'),
maxSize: 1,
},
selfHosting: {
usageEndpoint: 'http://' + usageAddress,
graphqlEndpoint: 'http://noop/',
applicationUrl: 'http://noop/',
},
});
const request = new Request('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'x-graphql-client-name': 'force-app',
'x-graphql-client-version': '1.0.0',
},
});
await client.collectUsage()(
{
document: parse(`query { hello }`),
schema: buildASTSchema(parse(sdl)),
contextValue: { request },
},
{},
'force-app~1.0.0~hash',
);
await waitForOperationsCollected(1);
// Retire without force should be blocked
const blockedResult = await execute({
document: RetireAppDeploymentWithProtectionDetails,
variables: {
input: {
target: {
byId: target.id,
},
appName: 'force-app',
appVersion: '1.0.0',
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(blockedResult.retireAppDeployment.error).not.toBeNull();
expect(blockedResult.retireAppDeployment.error?.message).toContain('blocked');
expect(blockedResult.retireAppDeployment.error?.protectionDetails).not.toBeNull();
// Retire with force should succeed
const retireResult = await execute({
document: RetireAppDeployment,
variables: {
input: {
target: {
byId: target.id,
},
appName: 'force-app',
appVersion: '1.0.0',
force: true,
},
},
authToken: token.secret,
}).then(res => res.expectNoGraphQLErrors());
expect(retireResult.retireAppDeployment).toEqual({
error: null,
ok: {
retiredAppDeployment: {
id: expect.any(String),
name: 'force-app',
status: 'retired',
version: '1.0.0',
},
},
});
});