diff --git a/package.json b/package.json
index 8fb7cfdeb..a66aa4d9f 100644
--- a/package.json
+++ b/package.json
@@ -26,8 +26,10 @@
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/line-clamp": "^0.4.2",
"@tailwindcss/typography": "^0.5.9",
+ "@testing-library/dom": "^9.2.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
+ "@testing-library/user-event": "^14.4.3",
"@types/debug": "^4.1.7",
"@types/node": "^18.11.18",
"@types/react": "^18.0.25",
@@ -40,6 +42,7 @@
"debug": "^4.3.4",
"history": "^5.3.0",
"jsdom": "^21.1.0",
+ "msw": "^1.2.1",
"postcss": "^8.4.21",
"qrcode.react": "^3.1.0",
"query-string": "^8.1.0",
diff --git a/src/app/app.tsx b/src/app/app.tsx
index d30933c03..5a9bd2466 100644
--- a/src/app/app.tsx
+++ b/src/app/app.tsx
@@ -9,7 +9,7 @@ import { ftuxRouter, router } from "./router";
import { selectOrigin } from "@app/env";
import { RouterProvider } from "react-router";
-const AppRouter = () => {
+export const AppRouter = () => {
const origin = useSelector(selectOrigin);
return (
diff --git a/src/app/router.tsx b/src/app/router.tsx
index a79a81d90..866c427cc 100644
--- a/src/app/router.tsx
+++ b/src/app/router.tsx
@@ -57,7 +57,12 @@ import {
} from "@app/ui";
import { ReactRouterErrorElement } from "@app/ui/shared/error-boundary";
-const ftuxRoutes: RouteObject[] = [
+const errorPatch = (appRoute: RouteObject) => ({
+ ...appRoute,
+ errorElement:
,
+});
+
+export const ftuxRoutes: RouteObject[] = [
{
path: routes.HOME_PATH,
element:
,
@@ -188,9 +193,9 @@ const ftuxRoutes: RouteObject[] = [
path: "*",
element:
,
},
-];
+].map(errorPatch);
-const appRoutes: RouteObject[] = [
+export const appRoutes: RouteObject[] = [
{
path: routes.HOME_PATH,
element:
,
@@ -470,12 +475,7 @@ const appRoutes: RouteObject[] = [
path: "*",
element:
,
},
-];
-
-const errorPatch = (appRoute: RouteObject) => ({
- ...appRoute,
- errorElement:
,
-});
+].map(errorPatch);
-export const ftuxRouter = createBrowserRouter(ftuxRoutes.map(errorPatch));
-export const router = createBrowserRouter(appRoutes.map(errorPatch));
+export const ftuxRouter = createBrowserRouter(ftuxRoutes);
+export const router = createBrowserRouter(appRoutes);
diff --git a/src/app/test/create-project.test.tsx b/src/app/test/create-project.test.tsx
new file mode 100644
index 000000000..2c9c2c8d8
--- /dev/null
+++ b/src/app/test/create-project.test.tsx
@@ -0,0 +1,84 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import { setupAppIntegrationTest } from "@app/test";
+
+describe("Create project flow", () => {
+ describe("existing user with ssh keys", () => {
+ it("should successfully provision resources within an environment", async () => {
+ const { App } = setupAppIntegrationTest({
+ initEntries: ["/create"],
+ });
+ render(
);
+
+ // deploy code landing page
+ const el = await screen.findByRole("button");
+ expect(el.textContent).toEqual("Deploy your code");
+ // go to next page
+ fireEvent.click(el);
+
+ // create environment page
+ const nameInput = await screen.findByRole("textbox", { name: "name" });
+ await act(async () => {
+ await userEvent.type(nameInput, "test-project");
+ });
+
+ const btn = await screen.findByRole("button", {
+ name: /Create Environment/,
+ });
+ // go to next page
+ fireEvent.click(btn);
+
+ // push your code page
+ await screen.findByText(/Push your code to continue/);
+
+ // settings page
+ await screen.findByText(/Review your Settings/);
+
+ const banner = await screen.findByRole("status");
+ expect(banner.textContent).toMatch(/Your code has a Dockerfile/);
+
+ const dbBtn = await screen.findByRole("button", {
+ name: /New Database/,
+ });
+ fireEvent.click(dbBtn);
+
+ const dbSelector = await screen.findByRole("combobox");
+ userEvent.selectOptions(dbSelector, "postgres:14");
+ const dbEnvVar = await screen.findByRole("textbox", { name: "envvar" });
+ expect(dbEnvVar).toHaveDisplayValue("DATABASE_URL");
+
+ const saveBtn = await screen.findByRole("button", {
+ name: /Save & Deploy/,
+ });
+
+ // go to next page
+ fireEvent.click(saveBtn);
+
+ // status page
+ await screen.findByRole("button", {
+ name: "View Environment",
+ });
+ const status = await screen.findByText(/Deployed today/);
+ expect(status).toBeInTheDocument();
+
+ await screen.findByText("Initial configuration");
+ await screen.findByText("App deployment");
+ await screen.findByText("Database provision test-app-1-postgres");
+ let ops = await screen.findAllByText("DONE");
+ expect(ops.length).toEqual(3);
+
+ // create https endpoint
+ await screen.findByText("Which service needs an endpoint?");
+
+ const vhostSelector = await screen.findAllByRole("radio");
+ fireEvent.click(vhostSelector[0]);
+ const httpBtn = await screen.findByText("Create endpoint");
+ fireEvent.click(httpBtn);
+
+ await screen.findByText("HTTPS endpoint provision");
+ ops = await screen.findAllByText("DONE");
+ expect(ops.length).toEqual(4);
+ });
+ });
+});
diff --git a/src/deploy/app/index.ts b/src/deploy/app/index.ts
index 11ece6e67..f57a2f6a0 100644
--- a/src/deploy/app/index.ts
+++ b/src/deploy/app/index.ts
@@ -22,12 +22,13 @@ import {
} from "../environment";
import { deserializeImage } from "../image";
import { deserializeDeployOperation, waitForOperation } from "../operation";
+import { DeployServiceResponse } from "../service";
import { selectDeploy } from "../slice";
export * from "./utils";
export interface DeployAppResponse {
- id: string;
+ id: number;
handle: string;
git_repo: string;
created_at: string;
@@ -40,13 +41,43 @@ export interface DeployAppResponse {
};
_embedded: {
// TODO: fill in
- services: { id: number }[];
+ services: DeployServiceResponse[];
current_image: any;
last_deploy_operation: any;
last_operation: any;
};
+ _type: "app";
}
+export const defaultAppResponse = (
+ p: Partial
= {},
+): DeployAppResponse => {
+ const now = new Date().toISOString();
+ return {
+ id: 1,
+ handle: "",
+ git_repo: "",
+ created_at: now,
+ updated_at: now,
+ deployment_method: "",
+ status: "provisioned",
+ _links: {
+ account: { href: "" },
+ current_configuration: { href: "" },
+ ...p._links,
+ },
+ _embedded: {
+ services: [],
+ current_image: null,
+ last_deploy_operation: null,
+ last_operation: null,
+ ...p._embedded,
+ },
+ ...p,
+ _type: "app",
+ };
+};
+
export const deserializeDeployApp = (payload: DeployAppResponse): DeployApp => {
const serviceIds: string[] = payload._embedded.services.map((s) => `${s.id}`);
const links = payload._links;
diff --git a/src/deploy/code-scan-result/index.ts b/src/deploy/code-scan-result/index.ts
index 29ebde111..0cbc3a4e7 100644
--- a/src/deploy/code-scan-result/index.ts
+++ b/src/deploy/code-scan-result/index.ts
@@ -7,13 +7,29 @@ export interface DeployCodeScanResponse {
dockerfile_present: boolean;
procfile_present: boolean;
_links: {
- self: LinkResponse;
app: LinkResponse;
operation: LinkResponse;
};
_type: "code_scan_result";
}
+export const defaultCodeScanResponse = (
+ c: Partial = {},
+): DeployCodeScanResponse => {
+ return {
+ id: 0,
+ aptible_yml_present: false,
+ dockerfile_present: false,
+ procfile_present: false,
+ _links: {
+ app: { href: "" },
+ operation: { href: "" },
+ },
+ _type: "code_scan_result",
+ ...c,
+ };
+};
+
export const fetchCodeScanResult = api.get<{ id: string }>(
"/code_scan_results/:id",
api.cache(),
diff --git a/src/deploy/database-images/index.ts b/src/deploy/database-images/index.ts
index d840ac5f8..4167775e7 100644
--- a/src/deploy/database-images/index.ts
+++ b/src/deploy/database-images/index.ts
@@ -20,6 +20,26 @@ export interface DeployDatabaseImageResponse {
_type: "database_image";
}
+export const defaultDatabaseImageResponse = (
+ i: Partial = {},
+): DeployDatabaseImageResponse => {
+ const now = new Date().toISOString();
+ return {
+ id: 0,
+ default: true,
+ description: "",
+ discoverable: true,
+ docker_repo: "",
+ type: "",
+ version: "",
+ visible: true,
+ created_at: now,
+ updated_at: now,
+ _type: "database_image",
+ ...i,
+ };
+};
+
export const deserializeDeployDatabaseImage = (
payload: DeployDatabaseImageResponse,
): DeployDatabaseImage => {
diff --git a/src/deploy/database/index.ts b/src/deploy/database/index.ts
index 0aa869ede..ed64cd0e9 100644
--- a/src/deploy/database/index.ts
+++ b/src/deploy/database/index.ts
@@ -65,6 +65,7 @@ export interface DeployDatabaseResponse {
disk: any;
last_operation: any;
};
+ _type: "database";
}
export interface BackupResponse {
@@ -85,6 +86,38 @@ export interface HalBackups {
backups: BackupResponse[];
}
+export const defaultDatabaseResponse = (
+ d: Partial = {},
+): DeployDatabaseResponse => {
+ const now = new Date().toISOString();
+ return {
+ id: 1,
+ handle: "",
+ provisioned: true,
+ type: "",
+ status: "provisioned",
+ docker_repo: "",
+ current_kms_arn: "",
+ connection_url: "",
+ created_at: now,
+ updated_at: now,
+ _links: {
+ account: { href: "" },
+ service: { href: "" },
+ database_image: { href: "" },
+ initialize_from: { href: "" },
+ ...d._links,
+ },
+ _embedded: {
+ disk: null,
+ last_operation: null,
+ ...d._embedded,
+ },
+ _type: "database",
+ ...d,
+ };
+};
+
export const deserializeDeployDatabase = (
payload: DeployDatabaseResponse,
): DeployDatabase => {
diff --git a/src/deploy/endpoint/index.ts b/src/deploy/endpoint/index.ts
index 76e1fe135..fdd2f0677 100644
--- a/src/deploy/endpoint/index.ts
+++ b/src/deploy/endpoint/index.ts
@@ -17,6 +17,8 @@ import type {
AppState,
DeployEndpoint,
DeployOperationResponse,
+ LinkResponse,
+ ProvisionableStatus,
} from "@app/types";
import { createSelector } from "@reduxjs/toolkit";
@@ -24,7 +26,75 @@ import { selectAppById, selectAppsByEnvId } from "../app";
import { selectDatabasesByEnvId } from "../database";
import { selectDeploy } from "../slice";
-export const deserializeDeployEndpoint = (payload: any): DeployEndpoint => {
+interface DeployEndpointResponse {
+ id: number;
+ acme: boolean;
+ acme_configuration: string;
+ acme_dns_challenge_host: string;
+ acme_status: string;
+ container_exposed_ports: string[];
+ container_port: string;
+ container_ports: string[];
+ default: boolean;
+ docker_name: string;
+ external_host: string;
+ external_http_port: string;
+ external_https_port: string;
+ internal: boolean;
+ ip_whitelist: string[];
+ platform: "alb" | "elb";
+ type: string;
+ user_domain: string;
+ virtual_domain: string;
+ status: ProvisionableStatus;
+ created_at: string;
+ updated_at: string;
+ _links: {
+ service: LinkResponse;
+ certificate: LinkResponse;
+ };
+ _type: "vhost";
+}
+
+export const defaultEndpointResponse = (
+ resp: Partial = {},
+): DeployEndpointResponse => {
+ const now = new Date().toISOString();
+ return {
+ id: 0,
+ acme: false,
+ acme_configuration: "",
+ acme_status: "",
+ acme_dns_challenge_host: "",
+ container_exposed_ports: [],
+ container_port: "",
+ container_ports: [],
+ default: true,
+ docker_name: "",
+ external_host: "",
+ external_http_port: "",
+ external_https_port: "",
+ internal: false,
+ ip_whitelist: [],
+ platform: "elb",
+ type: "",
+ user_domain: "",
+ virtual_domain: "",
+ status: "unknown",
+ created_at: now,
+ updated_at: now,
+ _links: {
+ service: { href: "" },
+ certificate: { href: "" },
+ },
+ _type: "vhost",
+ ...resp,
+ };
+};
+
+export const deserializeDeployEndpoint = (
+ payload: DeployEndpointResponse,
+): DeployEndpoint => {
return {
id: `${payload.id}`,
acme: payload.acme,
diff --git a/src/deploy/environment/index.ts b/src/deploy/environment/index.ts
index 52fce9942..e3a9a8715 100644
--- a/src/deploy/environment/index.ts
+++ b/src/deploy/environment/index.ts
@@ -25,7 +25,7 @@ import { selectDeploy } from "../slice";
import { selectStackById } from "../stack";
interface DeployEnvironmentResponse {
- id: string;
+ id: number;
handle: string;
created_at: string;
updated_at: string;
@@ -45,8 +45,40 @@ interface DeployEnvironmentResponse {
environment: LinkResponse;
stack: LinkResponse;
};
+ _type: "account";
}
+export const defaultEnvResponse = (
+ e: Partial = {},
+): DeployEnvironmentResponse => {
+ const now = new Date().toISOString();
+ return {
+ id: 1,
+ handle: "",
+ created_at: now,
+ updated_at: now,
+ type: "development",
+ activated: true,
+ container_count: 0,
+ domain_count: 0,
+ total_disk_size: 0,
+ total_app_count: 0,
+ app_container_count: 0,
+ database_container_count: 0,
+ total_database_count: 0,
+ sweetness_stack: "",
+ total_backup_size: 0,
+ onboarding_status: "unknown",
+ _links: {
+ environment: { href: "" },
+ stack: { href: "" },
+ ...e._links,
+ },
+ _type: "account",
+ ...e,
+ };
+};
+
export const deserializeDeployEnvironment = (
payload: DeployEnvironmentResponse,
): DeployEnvironment => ({
diff --git a/src/deploy/operation/index.ts b/src/deploy/operation/index.ts
index 0d90aef37..6adf48516 100644
--- a/src/deploy/operation/index.ts
+++ b/src/deploy/operation/index.ts
@@ -54,8 +54,51 @@ export interface DeployOperationResponse {
ssh_portal_connections: LinkResponse;
user: LinkResponse;
};
+ _type: "operation";
}
+export const defaultOperationResponse = (
+ op: Partial = {},
+): DeployOperationResponse => {
+ const now = new Date().toISOString();
+ return {
+ id: 0,
+ type: "",
+ status: "unknown",
+ created_at: now,
+ updated_at: now,
+ git_ref: "",
+ docker_ref: "",
+ container_count: 0,
+ encrypted_env_json_new: "",
+ destination_region: "",
+ automated: false,
+ cancelled: false,
+ aborted: false,
+ immediate: false,
+ provisioned_iops: 0,
+ ebs_volume_type: "",
+ encrypted_stack_settings: "",
+ instance_profile: "",
+ user_name: "",
+ user_email: "",
+ env: "",
+ _links: {
+ account: { href: "" },
+ code_scan_result: { href: "" },
+ ephemeral_sessions: { href: "" },
+ logs: { href: "" },
+ resource: { href: "" },
+ self: { href: "" },
+ ssh_portal_connections: { href: "" },
+ user: { href: "" },
+ ...op._links,
+ },
+ _type: "operation",
+ ...op,
+ };
+};
+
export const defaultDeployOperation = (
op: Partial = {},
): DeployOperation => {
diff --git a/src/deploy/service/index.ts b/src/deploy/service/index.ts
index cbfb09aa6..d7c3ac7da 100644
--- a/src/deploy/service/index.ts
+++ b/src/deploy/service/index.ts
@@ -12,8 +12,8 @@ import { selectDeploy } from "../slice";
export const DEFAULT_INSTANCE_CLASS: InstanceClass = "m4";
-interface DeployServiceResponse {
- id: string;
+export interface DeployServiceResponse {
+ id: number;
handle: string;
created_at: string;
updated_at: string;
@@ -24,8 +24,30 @@ interface DeployServiceResponse {
container_count: number;
container_memory_limit_mb: number;
instance_class: InstanceClass;
+ _type: "service";
}
+export const defaultServiceResponse = (
+ s: Partial = {},
+): DeployServiceResponse => {
+ const now = new Date().toISOString();
+ return {
+ id: 0,
+ handle: "",
+ docker_repo: "",
+ docker_ref: "",
+ process_type: "",
+ command: "",
+ container_count: 0,
+ container_memory_limit_mb: 0,
+ instance_class: "m4",
+ created_at: now,
+ updated_at: now,
+ _type: "service",
+ ...s,
+ };
+};
+
export const deserializeDeployService = (
payload: DeployServiceResponse,
): DeployService => {
diff --git a/src/deploy/stack/index.ts b/src/deploy/stack/index.ts
index 0f8ccba01..5adf2a796 100644
--- a/src/deploy/stack/index.ts
+++ b/src/deploy/stack/index.ts
@@ -6,10 +6,64 @@ import {
createTable,
mustSelectEntity,
} from "@app/slice-helpers";
-import type { AppState, DeployStack } from "@app/types";
+import type { AppState, DeployStack, LinkResponse } from "@app/types";
import { createSelector } from "@reduxjs/toolkit";
-export const deserializeDeployStack = (payload: any): DeployStack => {
+interface DeployStackResponse {
+ id: number;
+ name: string;
+ region: string;
+ default: boolean;
+ public: boolean;
+ created_at: string;
+ updated_at: string;
+ outbound_ip_addresses: string[];
+ memory_limits: boolean;
+ cpu_limits: boolean;
+ intrusion_detection: boolean;
+ expose_intrusion_detection_reports: boolean;
+ allow_t_instance_profile: boolean;
+ allow_c_instance_profile: boolean;
+ allow_m_instance_profile: boolean;
+ allow_r_instance_profile: boolean;
+ allow_granular_container_sizes: boolean;
+ _links: {
+ organization: LinkResponse;
+ };
+ _type: "stack";
+}
+
+export const defaultStackResponse = (
+ s: Partial = {},
+): DeployStackResponse => {
+ const now = new Date().toISOString();
+ return {
+ id: 1,
+ name: "",
+ region: "",
+ default: true,
+ public: true,
+ created_at: now,
+ updated_at: now,
+ outbound_ip_addresses: [],
+ memory_limits: false,
+ cpu_limits: false,
+ intrusion_detection: false,
+ expose_intrusion_detection_reports: false,
+ allow_c_instance_profile: true,
+ allow_m_instance_profile: true,
+ allow_r_instance_profile: true,
+ allow_t_instance_profile: true,
+ allow_granular_container_sizes: true,
+ _links: { organization: { href: "" } },
+ _type: "stack",
+ ...s,
+ };
+};
+
+export const deserializeDeployStack = (
+ payload: DeployStackResponse,
+): DeployStack => {
return {
id: `${payload.id}`,
name: payload.name,
diff --git a/src/mocks/data.ts b/src/mocks/data.ts
new file mode 100644
index 000000000..e1a4c9c53
--- /dev/null
+++ b/src/mocks/data.ts
@@ -0,0 +1,160 @@
+import {
+ defaultAppResponse,
+ defaultDatabaseImageResponse,
+ defaultEndpointResponse,
+ defaultEnvResponse,
+ defaultOperationResponse,
+ defaultServiceResponse,
+ defaultStackResponse,
+} from "@app/deploy";
+import { defaultCodeScanResponse } from "@app/deploy/code-scan-result";
+import { createEnv } from "@app/env";
+import { defaultOrgResponse } from "@app/organizations";
+import { defaultSshKeyResponse } from "@app/ssh-keys";
+import { defaultTokenResponse } from "@app/token";
+import { defaultUserResponse } from "@app/users";
+
+const idFactory = () => {
+ let id = 1;
+ return () => {
+ id += 1;
+ return id;
+ };
+};
+export const createId = idFactory();
+export const createText = (mixin: string, id: string | number = "1") => {
+ return `test-${mixin}-${id}`;
+};
+
+export const testEnv = createEnv({
+ origin: "app",
+ authUrl: "https://auth.aptible.com",
+ apiUrl: "https://api.aptible.com",
+ billingUrl: "https://billing.aptible.com",
+ legacyDashboardUrl: "https://dashboard.aptible.com",
+});
+
+const testUserId = createId();
+
+export const testToken = defaultTokenResponse({
+ access_token: `${createId()}`,
+ id: `${createId()}`,
+ _links: {
+ self: { href: "" },
+ user: { href: `${testEnv.authUrl}/users/${testUserId}` },
+ actor: null,
+ },
+});
+
+export const testUser = defaultUserResponse({ id: testUserId });
+export const testSshKey = defaultSshKeyResponse({ id: `${createId()}` });
+
+export const testOrg = defaultOrgResponse({
+ name: createText("org"),
+ id: `${createId()}`,
+});
+export const testStack = defaultStackResponse({
+ id: createId(),
+ name: createText("stack"),
+ region: "us-east-1",
+});
+
+export const testAccount = defaultEnvResponse({
+ id: createId(),
+ handle: createText("account"),
+ _links: {
+ stack: { href: `${testEnv.apiUrl}/stacks/${testStack.id}` },
+ environment: { href: "" },
+ },
+});
+
+export const testDatabaseId = createId();
+
+export const testServiceRails = defaultServiceResponse({
+ id: createId(),
+ handle: createText("rails s"),
+});
+export const testServiceSidekiq = defaultServiceResponse({
+ id: createId(),
+ handle: createText("rake sidekiq"),
+});
+
+export const testApp = defaultAppResponse({
+ id: createId(),
+ handle: createText("app"),
+ _links: {
+ account: { href: `${testEnv.apiUrl}/accounts/${testAccount.id}` },
+ current_configuration: { href: "" },
+ },
+ _embedded: {
+ current_image: null,
+ last_operation: null,
+ last_deploy_operation: null,
+ services: [testServiceRails, testServiceSidekiq],
+ },
+});
+
+export const testScanOperation = defaultOperationResponse({
+ id: createId(),
+ type: "scan_code",
+ status: "succeeded",
+ _links: {
+ code_scan_result: {
+ href: `${testEnv.apiUrl}/code_scan_results/${createId()}`,
+ },
+ resource: { href: `${testEnv.apiUrl}/apps/${testApp.id}` },
+ ephemeral_sessions: { href: "" },
+ self: { href: "" },
+ account: testApp._links.account,
+ ssh_portal_connections: { href: "" },
+ user: { href: "" },
+ logs: { href: "" },
+ },
+});
+
+export const testCodeScanResult = defaultCodeScanResponse({
+ id: createId(),
+ dockerfile_present: true,
+ _links: {
+ app: { href: `${testEnv.apiUrl}/apps/${testApp.id}` },
+ operation: { href: "" },
+ },
+});
+
+export const testPostgresDatabaseImage = defaultDatabaseImageResponse({
+ id: createId(),
+ type: "postgres",
+ version: "14",
+});
+
+export const testRedisDatabaseImage = defaultDatabaseImageResponse({
+ id: createId(),
+ type: "redis",
+ version: "5",
+});
+
+export const testDatabaseOp = defaultOperationResponse({
+ id: createId(),
+ type: "provision",
+ status: "succeeded",
+ _links: {
+ resource: {
+ href: `${testEnv.apiUrl}/databases/${testDatabaseId}`,
+ },
+ account: { href: `${testEnv.apiUrl}/accounts/${testAccount.id}` },
+ code_scan_result: { href: "" },
+ self: { href: "" },
+ ssh_portal_connections: { href: "" },
+ ephemeral_sessions: { href: "" },
+ logs: { href: "" },
+ user: { href: "" },
+ },
+});
+
+export const testEndpoint = defaultEndpointResponse({
+ id: createId(),
+ _links: {
+ service: { href: `${testEnv.apiUrl}/services/${testServiceRails.id}` },
+ certificate: { href: "" },
+ },
+});
diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts
new file mode 100644
index 000000000..125f3ef81
--- /dev/null
+++ b/src/mocks/handlers.ts
@@ -0,0 +1,289 @@
+import {
+ createId,
+ testAccount,
+ testApp,
+ testCodeScanResult,
+ testDatabaseId,
+ testDatabaseOp,
+ testEndpoint,
+ testEnv,
+ testOrg,
+ testPostgresDatabaseImage,
+ testRedisDatabaseImage,
+ testScanOperation,
+ testSshKey,
+ testStack,
+ testToken,
+ testUser,
+} from "./data";
+import { defaultDatabaseResponse, defaultOperationResponse } from "@app/deploy";
+import { RestRequest, rest } from "msw";
+
+const isValidToken = ({ headers }: RestRequest) => {
+ const authorization = headers.get("authorization");
+ return `Bearer ${testToken.access_token}` === authorization;
+};
+
+const authHandlers = [
+ rest.get(`${testEnv.authUrl}/current_token`, (_, res, ctx) => {
+ return res(ctx.json(testToken));
+ }),
+ rest.get(`${testEnv.authUrl}/organizations`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ return res(ctx.json({ _embedded: { organizations: [testOrg] } }));
+ }),
+ rest.get(`${testEnv.authUrl}/organizations/:orgId/users`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+
+ return res(ctx.json({ _embedded: { users: [testUser] } }));
+ }),
+ rest.get(`${testEnv.authUrl}/users/:userId/ssh_keys`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+
+ return res(ctx.json({ _embedded: { ssh_keys: [testSshKey] } }));
+ }),
+];
+
+const apiHandlers = [
+ rest.get(`${testEnv.apiUrl}/stacks`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ return res(ctx.json({ _embedded: { stacks: [testStack] } }));
+ }),
+ rest.get(`${testEnv.apiUrl}/accounts`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ return res(ctx.json({ _embedded: { accounts: [] } }));
+ }),
+ rest.get(`${testEnv.apiUrl}/databases`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ return res(ctx.json({ _embedded: { databases: [] } }));
+ }),
+ rest.post(
+ `${testEnv.apiUrl}/databases/:id/operations`,
+ async (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ return res(ctx.json(testDatabaseOp));
+ },
+ ),
+ rest.get(`${testEnv.apiUrl}/apps`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ return res(ctx.json({ _embedded: { apps: [] } }));
+ }),
+ rest.get(`${testEnv.apiUrl}/apps/:id`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ return res(ctx.json(testApp));
+ }),
+ rest.get(`${testEnv.apiUrl}/apps/:id/operations`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ return res(
+ ctx.json({
+ _embedded: { operations: [testScanOperation] },
+ }),
+ );
+ }),
+ rest.post(`${testEnv.apiUrl}/apps/:id/operations`, async (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ const data = await req.json();
+ return res(
+ ctx.json(
+ defaultOperationResponse({
+ id: createId(),
+ type: data.type,
+ env: data.env,
+ status: "succeeded",
+ _links: {
+ resource: { href: `${testEnv.apiUrl}/apps/${req.params.id}` },
+ account: testApp._links.account,
+ code_scan_result: { href: "" },
+ self: { href: "" },
+ ssh_portal_connections: { href: "" },
+ ephemeral_sessions: { href: "" },
+ logs: { href: "" },
+ user: { href: "" },
+ },
+ }),
+ ),
+ );
+ }),
+ rest.get(`${testEnv.apiUrl}/apps/:id/vhosts`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ return res(ctx.json({ _embedded: { vhosts: [] } }));
+ }),
+ rest.get(
+ `${testEnv.apiUrl}/apps/:id/service_definitions`,
+ (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ return res(ctx.json({ _embedded: { service_definitions: [] } }));
+ },
+ ),
+ rest.post(`${testEnv.apiUrl}/accounts`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+
+ return res(ctx.json(testAccount));
+ }),
+ rest.get(`${testEnv.apiUrl}/accounts/:id`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+
+ return res(ctx.json(testAccount));
+ }),
+ rest.patch(`${testEnv.apiUrl}/accounts/:id`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+
+ return res(
+ ctx.json({
+ ...testAccount,
+ onboarding_status: req.headers.get("onboarding_status"),
+ }),
+ );
+ }),
+ rest.get(`${testEnv.apiUrl}/accounts/:envId/databases`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+
+ return res(ctx.json({ _embedded: { databases: [] } }));
+ }),
+ rest.post(
+ `${testEnv.apiUrl}/accounts/:envId/databases`,
+ async (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+
+ const data = await req.json();
+ return res(
+ ctx.json(
+ defaultDatabaseResponse({
+ id: testDatabaseId,
+ handle: data.handle,
+ type: data.type,
+ _links: {
+ account: {
+ href: `${testEnv.apiUrl}/accounts/${data.account_id}`,
+ },
+ initialize_from: { href: "" },
+ database_image: {
+ href: `${testEnv.apiUrl}/database_images/${data.database_image_id}`,
+ },
+ service: { href: "" },
+ },
+ }),
+ ),
+ );
+ },
+ ),
+ rest.get(`${testEnv.apiUrl}/accounts/:envId/operations`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+
+ return res(
+ ctx.json({
+ _embedded: { databases: [testScanOperation, testDatabaseOp] },
+ }),
+ );
+ }),
+ rest.post(`${testEnv.apiUrl}/accounts/:envId/apps`, async (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+
+ const data = await req.json();
+ return res(
+ ctx.json({
+ ...testApp,
+ handle: data.handle,
+ _links: {
+ account: { href: `${testEnv.apiUrl}/accounts/${data.account_id}` },
+ current_configuration: { href: "" },
+ },
+ }),
+ );
+ }),
+ rest.get(`${testEnv.apiUrl}/code_scan_results/:id`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ return res(ctx.json(testCodeScanResult));
+ }),
+ rest.get(`${testEnv.apiUrl}/database_images`, (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+ return res(
+ ctx.json({
+ _embedded: {
+ database_images: [testRedisDatabaseImage, testPostgresDatabaseImage],
+ },
+ }),
+ );
+ }),
+ rest.post(`${testEnv.apiUrl}/services/:id/vhosts`, async (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+
+ return res(ctx.json(testEndpoint));
+ }),
+ rest.post(
+ `${testEnv.apiUrl}/vhosts/:id/operations`,
+ async (req, res, ctx) => {
+ if (!isValidToken(req)) {
+ return res(ctx.status(401));
+ }
+
+ return res(
+ ctx.json(
+ defaultOperationResponse({
+ id: createId(),
+ type: "provision",
+ status: "succeeded",
+ _links: {
+ resource: { href: `${testEnv.apiUrl}/vhosts/${testEndpoint.id}` },
+ account: testApp._links.account,
+ code_scan_result: { href: "" },
+ self: { href: "" },
+ ssh_portal_connections: { href: "" },
+ ephemeral_sessions: { href: "" },
+ logs: { href: "" },
+ user: { href: "" },
+ },
+ }),
+ ),
+ );
+ },
+ ),
+];
+
+export const handlers = [...authHandlers, ...apiHandlers];
diff --git a/src/mocks/index.ts b/src/mocks/index.ts
new file mode 100644
index 000000000..1a240d2e4
--- /dev/null
+++ b/src/mocks/index.ts
@@ -0,0 +1,2 @@
+export * from "./server";
+export * from "./data";
diff --git a/src/mocks/server.ts b/src/mocks/server.ts
new file mode 100644
index 000000000..2908a09be
--- /dev/null
+++ b/src/mocks/server.ts
@@ -0,0 +1,6 @@
+import { handlers } from "./handlers";
+// src/mocks/server.js
+import { setupServer } from "msw/node";
+
+// This configures a request mocking server with the given request handlers.
+export const server = setupServer(...handlers);
diff --git a/src/organizations/index.ts b/src/organizations/index.ts
index ffa9e0da0..17186b69b 100644
--- a/src/organizations/index.ts
+++ b/src/organizations/index.ts
@@ -42,6 +42,37 @@ interface OrganizationResponse {
_type: "organization";
}
+export const defaultOrgResponse = (
+ o: Partial = {},
+): OrganizationResponse => {
+ const now = new Date().toISOString();
+ return {
+ address: "",
+ city: "",
+ created_at: now,
+ updated_at: now,
+ emergency_phone: "",
+ id: "",
+ name: "",
+ ops_alert_email: "",
+ primary_phone: "",
+ security_alert_email: "",
+ state: "",
+ zip: "",
+ _links: {
+ billing_detail: { href: "" },
+ invitations: { href: "" },
+ roles: { href: "" },
+ security_officer: { href: "" },
+ self: { href: "" },
+ users: { href: "" },
+ ...o._links,
+ },
+ _type: "organization",
+ ...o,
+ };
+};
+
export const ORGANIZATIONS_NAME = "organizations";
export const ORGANIZATION_SELECTED_NAME = "organizationSelected";
diff --git a/src/projects/index.ts b/src/projects/index.ts
index d2b12847b..de86ef448 100644
--- a/src/projects/index.ts
+++ b/src/projects/index.ts
@@ -79,7 +79,7 @@ export const createProject = thunks.create(
}
log(envCtx);
- envId = envCtx.json.data.id;
+ envId = `${envCtx.json.data.id}`;
}
const appCtx = yield* call(
diff --git a/src/ssh-keys/index.ts b/src/ssh-keys/index.ts
index 4ff88854f..8a0b966fd 100644
--- a/src/ssh-keys/index.ts
+++ b/src/ssh-keys/index.ts
@@ -1,9 +1,40 @@
import { authApi } from "@app/api";
+import { HalEmbedded } from "@app/types";
-export const fetchSSHKeys = authApi.get<{ userId: string }>(
- "/users/:userId/ssh_keys",
- authApi.cache(),
-);
+interface SshKeyResponse {
+ id: string;
+ name: string;
+ md5_fingerprint: string;
+ public_key_fingerprint: string;
+ sha256_fingerprint: string;
+ ssh_public_key: string;
+ created_at: string;
+ updated_at: string;
+ _type: "ssh_key";
+}
+
+export const defaultSshKeyResponse = (
+ s: Partial = {},
+): SshKeyResponse => {
+ const now = new Date().toISOString();
+ return {
+ id: "",
+ name: "",
+ md5_fingerprint: "",
+ public_key_fingerprint: "",
+ sha256_fingerprint: "",
+ ssh_public_key: "",
+ created_at: now,
+ updated_at: now,
+ _type: "ssh_key",
+ ...s,
+ };
+};
+
+export const fetchSSHKeys = authApi.get<
+ { userId: string },
+ HalEmbedded<{ ssh_keys: SshKeyResponse[] }>
+>("/users/:userId/ssh_keys", authApi.cache());
export const addSSHKey = authApi.post<{
name: string;
diff --git a/src/test/index.tsx b/src/test/index.tsx
index afe60716b..b446c5f9c 100644
--- a/src/test/index.tsx
+++ b/src/test/index.tsx
@@ -1,24 +1,87 @@
import { Provider } from "react-redux";
-import { Route, Routes } from "react-router";
+import {
+ Route,
+ RouteObject,
+ RouterProvider,
+ Routes,
+ createMemoryRouter,
+} from "react-router";
import { MemoryRouter } from "react-router-dom";
-import { applyMiddleware, createStore } from "redux";
import { prepareStore } from "saga-query";
-import { reducers, sagas } from "@app/app";
+import { reducers, rootEntities, sagas } from "@app/app";
+import { ftuxRoutes } from "@app/app/router";
+import { bootup } from "@app/bootup";
+import { testEnv } from "@app/mocks";
import type { AppState } from "@app/types";
+import { configureStore } from "@reduxjs/toolkit";
+import { REHYDRATE } from "redux-persist";
+
+export const setupTestStore = (initState: Partial = {}) => {
+ const middleware = [];
+ const prepared = prepareStore({
+ reducers: reducers,
+ sagas: sagas,
+ });
+
+ middleware.push(...prepared.middleware);
+
+ const store = configureStore({
+ preloadedState: { ...initState, entities: rootEntities },
+ reducer: prepared.reducer,
+ devTools: false,
+ middleware: middleware,
+ });
+
+ prepared.run();
+
+ return { store };
+};
+
+/**
+ * This function helps simulate booting the entire app as if it were
+ * the browser. All of redux, redux-saga, and redux-persist are loaded
+ * and configured.
+ *
+ * We also dispatch the `booup()` saga which fetches a bunch of data.
+ */
+export const setupAppIntegrationTest = (
+ {
+ routes = ftuxRoutes,
+ initState = {},
+ initEntries = [],
+ }: Partial<{
+ routes: RouteObject[];
+ initState: Partial;
+ initEntries: string[];
+ }> = {
+ routes: ftuxRoutes,
+ initState: {},
+ initEntries: [],
+ },
+) => {
+ const router = createMemoryRouter(routes, { initialEntries: initEntries });
+ const { store } = setupTestStore({
+ ...initState,
+ env: testEnv,
+ });
+ store.dispatch(bootup());
+ store.dispatch({ type: REHYDRATE });
+ const App = () => {
+ return (
+
+
+
+ );
+ };
+ return { store, router, App };
+};
export const setupIntegrationTest = (
initState: Partial = {},
path = "",
) => {
- const prepared = prepareStore({ reducers, sagas });
-
- const store = createStore(
- prepared.reducer,
- initState as AppState,
- applyMiddleware(...prepared.middleware),
- );
- prepared.run();
+ const { store } = setupTestStore(initState);
const TestProvider = ({ children }: { children: React.ReactNode }) => {
return (
@@ -31,5 +94,5 @@ export const setupIntegrationTest = (
);
};
- return { store, TestProvider, history };
+ return { store, TestProvider };
};
diff --git a/src/test/setup.ts b/src/test/setup.ts
index d0de870dc..a540ff9e2 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -1 +1,25 @@
-import "@testing-library/jest-dom";
+import { server } from "@app/mocks";
+import matchers, {
+ TestingLibraryMatchers,
+} from "@testing-library/jest-dom/matchers";
+import { expect } from "vitest";
+
+declare global {
+ namespace Vi {
+ interface JestAssertion
+ extends jest.Matchers,
+ TestingLibraryMatchers {}
+ }
+}
+
+expect.extend(matchers);
+
+// Establish API mocking before all tests.
+beforeAll(() => server.listen());
+
+// Reset any request handlers that we may add during the tests,
+// so they don't affect other tests.
+afterEach(() => server.resetHandlers());
+
+// Clean up after the tests are finished.
+afterAll(() => server.close());
diff --git a/src/token/index.ts b/src/token/index.ts
index 8d7277204..754165b1c 100644
--- a/src/token/index.ts
+++ b/src/token/index.ts
@@ -39,6 +39,31 @@ export interface JWTToken {
name: string;
}
+export const defaultTokenResponse = (
+ t: Partial = {},
+): TokenSuccessResponse => {
+ const now = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(now.getDate() + 1);
+ return {
+ access_token: "",
+ created_at: now.toISOString(),
+ expires_at: tomorrow.toISOString(),
+ expires_in: tomorrow.toISOString(),
+ scope: "manage",
+ id: "",
+ token_type: "",
+ _links: {
+ self: { href: "" },
+ user: { href: "" },
+ actor: null,
+ ...t._links,
+ },
+ _type: "token",
+ ...t,
+ };
+};
+
export const defaultJWTToken = (t: Partial = {}): JWTToken => {
return {
id: "",
diff --git a/src/types/helpers.ts b/src/types/helpers.ts
index f94f2ad33..5051a33f3 100644
--- a/src/types/helpers.ts
+++ b/src/types/helpers.ts
@@ -3,6 +3,11 @@ import type { SagaIterator } from "saga-query";
// https://stackoverflow.com/a/47636222
export const excludesFalse = (n?: T): n is T => Boolean(n);
+// https://stackoverflow.com/a/72311590
+export type DeepPartial = {
+ [K in keyof T]?: T[K] extends object ? DeepPartial : T[K];
+};
+
export type ApiGen