Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Get Started checklist #232

Merged
merged 6 commits into from
Jul 11, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions codegen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ generates:
BillingPaymentMethod: 'StripeTypes.PaymentMethod.Card'
BillingDetails: 'StripeTypes.PaymentMethod.BillingDetails'
BillingInvoice: 'StripeTypes.Invoice'
OrganizationGetStarted: ../shared/entities#OrganizationGetStarted as OrganizationGetStartedMapper
plugins:
- add:
content: "import { StripeTypes } from '@hive/stripe-billing';"
Expand Down
25 changes: 25 additions & 0 deletions integration-tests/testkit/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,31 @@ export function createOrganization(input: CreateOrganizationInput, authToken: st
});
}

export function getOrganizationGetStartedProgress(organizationId: string, authToken: string) {
return execute({
document: gql(/* GraphQL */ `
query getOrganizationGetStartedProgress($organizationId: ID!) {
organization(selector: { organization: $organizationId }) {
organization {
getStarted {
creatingProject
publishingSchema
checkingSchema
invitingMembers
reportingOperations
enablingUsageBasedBreakingChanges
}
}
}
}
`),
authToken,
variables: {
organizationId,
},
});
}

export function renameOrganization(input: UpdateOrganizationNameInput, authToken: string) {
return execute({
document: gql(/* GraphQL */ `
Expand Down
229 changes: 229 additions & 0 deletions integration-tests/tests/api/organization/get-started.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import {
createOrganization,
getOrganizationGetStartedProgress,
createProject,
createToken,
publishSchema,
checkSchema,
joinOrganization,
waitFor,
setTargetValidation,
} from '../../../testkit/flow';
import { authenticate } from '../../../testkit/auth';
import { collect } from '../../../testkit/usage';
import { TargetAccessScope, ProjectType, ProjectAccessScope, OrganizationAccessScope } from '@app/gql/graphql';

async function getSteps({ organization, token }: { organization: string; token: string }) {
const result = await getOrganizationGetStartedProgress(organization, token);

expect(result.body.errors).not.toBeDefined();

return result.body.data?.organization?.organization.getStarted;
}

test('freshly created organization has Get Started progress at 0%', async () => {
const { access_token } = await authenticate('main');
const orgResult = await createOrganization(
{
name: 'foo',
},
access_token
);
const org = orgResult.body.data!.createOrganization.ok!.createdOrganizationPayload.organization;

const steps = await getSteps({
organization: org.cleanId,
token: access_token,
});

expect(steps?.creatingProject).toBe(false);
expect(steps?.publishingSchema).toBe(false);
expect(steps?.checkingSchema).toBe(false);
expect(steps?.invitingMembers).toBe(false);
expect(steps?.reportingOperations).toBe(false);
expect(steps?.enablingUsageBasedBreakingChanges).toBe(false);
});

test('completing each step should result in updated Get Started progress', async () => {
const { access_token } = await authenticate('main');
const orgResult = await createOrganization(
{
name: 'foo',
},
access_token
);
const org = orgResult.body.data!.createOrganization.ok!.createdOrganizationPayload.organization;

// Step: creating project

const projectResult = await createProject(
{
organization: org.cleanId,
name: 'foo',
type: ProjectType.Single,
},
access_token
);

let steps = await getSteps({
organization: org.cleanId,
token: access_token,
});

expect(steps?.creatingProject).toBe(true); // modified
expect(steps?.publishingSchema).toBe(false);
expect(steps?.checkingSchema).toBe(false);
expect(steps?.invitingMembers).toBe(false);
expect(steps?.reportingOperations).toBe(false);
expect(steps?.enablingUsageBasedBreakingChanges).toBe(false);

expect(projectResult.body.errors).not.toBeDefined();

const target = projectResult.body.data?.createProject.ok?.createdTargets.find(t => t.name === 'production');
const project = projectResult.body.data?.createProject.ok?.createdProject;

if (!target || !project) {
throw new Error('Failed to create project');
}

const tokenResult = await createToken(
{
name: 'test',
organization: org.cleanId,
project: project.cleanId,
target: target.cleanId,
organizationScopes: [OrganizationAccessScope.Read],
projectScopes: [ProjectAccessScope.Read],
targetScopes: [
TargetAccessScope.Read,
TargetAccessScope.RegistryRead,
TargetAccessScope.RegistryWrite,
TargetAccessScope.Settings,
],
},
access_token
);

expect(tokenResult.body.errors).not.toBeDefined();

const token = tokenResult.body.data!.createToken.ok!.secret;

// Step: publishing schema

await publishSchema(
{
author: 'test',
commit: 'test',
sdl: 'type Query { foo: String }',
},
token
);

steps = await getSteps({
organization: org.cleanId,
token: access_token,
});

expect(steps?.creatingProject).toBe(true);
expect(steps?.publishingSchema).toBe(true); // modified
expect(steps?.checkingSchema).toBe(false);
expect(steps?.invitingMembers).toBe(false);
expect(steps?.reportingOperations).toBe(false);
expect(steps?.enablingUsageBasedBreakingChanges).toBe(false);

// Step: checking schema

await checkSchema(
{
sdl: 'type Query { foo: String bar: String }',
},
token
);

steps = await getSteps({
organization: org.cleanId,
token: access_token,
});

expect(steps?.creatingProject).toBe(true);
expect(steps?.publishingSchema).toBe(true);
expect(steps?.checkingSchema).toBe(true); // modified
expect(steps?.invitingMembers).toBe(false);
expect(steps?.reportingOperations).toBe(false);
expect(steps?.enablingUsageBasedBreakingChanges).toBe(false);

// Step: inviting members

const { access_token: member_access_token } = await authenticate('extra');
await joinOrganization(org.inviteCode, member_access_token);

steps = await getSteps({
organization: org.cleanId,
token: access_token,
});

expect(steps?.creatingProject).toBe(true);
expect(steps?.publishingSchema).toBe(true);
expect(steps?.checkingSchema).toBe(true);
expect(steps?.invitingMembers).toBe(true); // modified
expect(steps?.reportingOperations).toBe(false);
expect(steps?.enablingUsageBasedBreakingChanges).toBe(false);

// Step: reporting operations

await collect({
operations: [
{
operationName: 'foo',
operation: 'query foo { foo }',
fields: ['Query', 'Query.foo'],
execution: {
duration: 2_000_000,
ok: true,
errorsTotal: 0,
},
},
],
token,
authorizationHeader: 'authorization',
});
await waitFor(10_000);

steps = await getSteps({
organization: org.cleanId,
token: access_token,
});

expect(steps?.creatingProject).toBe(true);
expect(steps?.publishingSchema).toBe(true);
expect(steps?.checkingSchema).toBe(true);
expect(steps?.invitingMembers).toBe(true);
expect(steps?.reportingOperations).toBe(true); // modified
expect(steps?.enablingUsageBasedBreakingChanges).toBe(false);

// Step: reporting operations

await setTargetValidation(
{
enabled: true,
target: target.cleanId,
project: project.cleanId,
organization: org.cleanId,
},
{
token,
}
);

steps = await getSteps({
organization: org.cleanId,
token: access_token,
});

expect(steps?.creatingProject).toBe(true);
expect(steps?.publishingSchema).toBe(true);
expect(steps?.checkingSchema).toBe(true);
expect(steps?.invitingMembers).toBe(true);
expect(steps?.reportingOperations).toBe(true);
expect(steps?.enablingUsageBasedBreakingChanges).toBe(true); // modified
});
2 changes: 1 addition & 1 deletion packages/libraries/client/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const version = '0.16.0';
export const version = '0.17.0';
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,8 @@ export default gql`
duration: Int!
count: SafeInt!
}

extend type OrganizationGetStarted {
reportingOperations: Boolean!
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { cache } from '../../../shared/helpers';
import { AuthManager } from '../../auth/providers/auth-manager';
import { TargetAccessScope } from '../../auth/providers/target-access';
import { Logger } from '../../shared/providers/logger';
import type { TargetSelector } from '../../shared/providers/storage';
import type { TargetSelector, OrganizationSelector } from '../../shared/providers/storage';
import { Storage } from '../../shared/providers/storage';
import { OperationsReader } from './operations-reader';

const DAY_IN_MS = 86_400_000;
Expand Down Expand Up @@ -57,7 +58,12 @@ interface ReadFieldStatsOutput {
export class OperationsManager {
private logger: Logger;

constructor(logger: Logger, private authManager: AuthManager, private reader: OperationsReader) {
constructor(
logger: Logger,
private authManager: AuthManager,
private reader: OperationsReader,
private storage: Storage
) {
this.logger = logger.child({ source: 'OperationsManager' });
}

Expand Down Expand Up @@ -435,4 +441,26 @@ export class OperationsManager {
operations,
});
}

async hasOperationsForOrganization(selector: OrganizationSelector): Promise<boolean> {
const targets = await this.storage.getTargetIdsOfOrganization(selector);

if (targets.length === 0) {
return false;
}

const total = await this.reader.countOperationsForTargets({
targets,
});

if (total > 0) {
await this.storage.completeGetStartedStep({
organization: selector.organization,
step: 'reportingOperations',
});
return true;
}

return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,26 @@ export class OperationsReader {
});
}

async countOperationsForTargets({ targets }: { targets: readonly string[] }): Promise<number> {
const result = await this.clickHouse.query<{
total: string;
}>({
query: `SELECT sum(total) as total from operations_new_hourly_mv WHERE target IN ('${targets.join(`', '`)}')`,
queryId: 'count_operations_for_targets',
timeout: 15_000,
});

if (result.data.length === 0) {
return 0;
}

if (result.data.length > 1) {
throw new Error('Too many rows returned, expected 1');
}

return ensureNumber(result.data[0].total);
}

async adminCountOperationsPerTarget({ daysLimit }: { daysLimit: number }) {
const result = await this.clickHouse.query<{
total: string;
Expand Down
11 changes: 11 additions & 0 deletions packages/services/api/src/modules/operations/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,17 @@ export const resolvers: OperationsModule.Resolvers = {
},
OperationStatsConnection: createConnection(),
ClientStatsConnection: createConnection(),
OrganizationGetStarted: {
reportingOperations(organization, _, { injector }) {
if (organization.reportingOperations === true) {
return organization.reportingOperations;
}

return injector.get(OperationsManager).hasOperationsForOrganization({
organization: organization.id,
});
},
},
};

function transformPercentile(value: number | null): number {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export default gql`
me: Member!
members: MemberConnection!
inviteCode: String!
getStarted: OrganizationGetStarted!
}

type OrganizationConnection {
Expand All @@ -114,4 +115,12 @@ export default gql`
selector: OrganizationSelector!
organization: Organization!
}

type OrganizationGetStarted {
creatingProject: Boolean!
publishingSchema: Boolean!
checkingSchema: Boolean!
invitingMembers: Boolean!
enablingUsageBasedBreakingChanges: Boolean!
}
`;