Skip to content

Commit

Permalink
chore(site): Add unit tests, mocks (#514)
Browse files Browse the repository at this point in the history
* Extract and unit test redirect functions

* Move router to make app more testable

* Make mock entities more consistent

* Use labels instead of placeholders

* Fill out handlers

* Lint

* Reorganize App

* Make mock entities reference each other

* Add describes in tests

* Clean up api and mocks
  • Loading branch information
presleyp committed Mar 23, 2022
1 parent 3bf5ceb commit f2ac81c
Show file tree
Hide file tree
Showing 10 changed files with 161 additions and 118 deletions.
76 changes: 76 additions & 0 deletions site/src/AppRouter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from "react"
import { Routes, Route } from "react-router-dom"
import { RequireAuth, AuthAndNav } from "./components"
import { IndexPage } from "./pages"
import { NotFoundPage } from "./pages/404"
import { CliAuthenticationPage } from "./pages/cli-auth"
import { HealthzPage } from "./pages/healthz"
import { SignInPage } from "./pages/login"
import { ProjectsPage } from "./pages/projects"
import { ProjectPage } from "./pages/projects/[organization]/[project]"
import { CreateWorkspacePage } from "./pages/projects/[organization]/[project]/create"
import { WorkspacePage } from "./pages/workspaces/[workspace]"

export const AppRouter: React.FC = () => (
<Routes>
<Route path="/">
<Route
index
element={
<RequireAuth>
<IndexPage />
</RequireAuth>
}
/>

<Route path="login" element={<SignInPage />} />
<Route path="healthz" element={<HealthzPage />} />
<Route path="cli-auth" element={<CliAuthenticationPage />} />

<Route path="projects">
<Route
index
element={
<AuthAndNav>
<ProjectsPage />
</AuthAndNav>
}
/>
<Route path=":organization/:project">
<Route
index
element={
<AuthAndNav>
<ProjectPage />
</AuthAndNav>
}
/>
<Route
path="create"
element={
<RequireAuth>
<CreateWorkspacePage />
</RequireAuth>
}
/>
</Route>
</Route>

<Route path="workspaces">
<Route
path=":workspace"
element={
<AuthAndNav>
<WorkspacePage />
</AuthAndNav>
}
/>
</Route>

{/* Using path="*"" means "match anything", so this route
acts like a catch-all for URLs that we don't have explicit
routes for. */}
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
)
20 changes: 0 additions & 20 deletions site/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,6 @@ export const provisioners: Types.Provisioner[] = [
},
]

export namespace Project {
export const create = async (request: Types.CreateProjectRequest): Promise<Types.Project> => {
const response = await fetch(`/api/v2/projects/${request.organizationId}/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
})

const body = await response.json()
await mutate("/api/v2/projects")
if (!response.ok) {
throw new Error(body.message)
}

return body
}
}

export namespace Workspace {
export const create = async (request: Types.CreateWorkspaceRequest): Promise<Types.Workspace> => {
const response = await fetch(`/api/v2/users/me/workspaces`, {
Expand Down
76 changes: 3 additions & 73 deletions site/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,10 @@ import CssBaseline from "@material-ui/core/CssBaseline"
import ThemeProvider from "@material-ui/styles/ThemeProvider"
import { SWRConfig } from "swr"
import { light } from "./theme"
import { BrowserRouter as Router, Route, Routes } from "react-router-dom"
import { BrowserRouter as Router } from "react-router-dom"

import { CliAuthenticationPage } from "./pages/cli-auth"
import { NotFoundPage } from "./pages/404"
import { IndexPage } from "./pages/index"
import { SignInPage } from "./pages/login"
import { ProjectsPage } from "./pages/projects"
import { ProjectPage } from "./pages/projects/[organization]/[project]"
import { CreateWorkspacePage } from "./pages/projects/[organization]/[project]/create"
import { WorkspacePage } from "./pages/workspaces/[workspace]"
import { HealthzPage } from "./pages/healthz"
import { AuthAndNav, RequireAuth } from "./components/Page"
import { XServiceProvider } from "./xServices/StateContext"
import { AppRouter } from "./AppRouter"
import "./theme/global-fonts"

export const App: React.FC = () => {
Expand All @@ -42,68 +33,7 @@ export const App: React.FC = () => {
<XServiceProvider>
<ThemeProvider theme={light}>
<CssBaseline />

<Routes>
<Route path="/">
<Route
index
element={
<RequireAuth>
<IndexPage />
</RequireAuth>
}
/>

<Route path="login" element={<SignInPage />} />
<Route path="healthz" element={<HealthzPage />} />
<Route path="cli-auth" element={<CliAuthenticationPage />} />

<Route path="projects">
<Route
index
element={
<AuthAndNav>
<ProjectsPage />
</AuthAndNav>
}
/>
<Route path=":organization/:project">
<Route
index
element={
<AuthAndNav>
<ProjectPage />
</AuthAndNav>
}
/>
<Route
path="create"
element={
<RequireAuth>
<CreateWorkspacePage />
</RequireAuth>
}
/>
</Route>
</Route>

<Route path="workspaces">
<Route
path=":workspace"
element={
<AuthAndNav>
<WorkspacePage />
</AuthAndNav>
}
/>
</Route>

{/* Using path="*"" means "match anything", so this route
acts like a catch-all for URLs that we don't have explicit
routes for. */}
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
<AppRouter />
</ThemeProvider>
</XServiceProvider>
</SWRConfig>
Expand Down
4 changes: 3 additions & 1 deletion site/src/components/Page/RequireAuth.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useActor } from "@xstate/react"
import React, { useContext } from "react"
import { Navigate, useLocation } from "react-router"
import { embedRedirect } from "../../util/redirect"
import { XServiceContext } from "../../xServices/StateContext"
import { FullScreenLoader } from "../Loader/FullScreenLoader"

Expand All @@ -12,9 +13,10 @@ export const RequireAuth: React.FC<RequireAuthProps> = ({ children }) => {
const xServices = useContext(XServiceContext)
const [userState] = useActor(xServices.userXService)
const location = useLocation()
const redirectTo = embedRedirect(location.pathname)

if (userState.matches("signedOut") || !userState.context.me) {
return <Navigate to={"/login?redirect=" + encodeURIComponent(location.pathname)} />
return <Navigate to={redirectTo} />
} else if (userState.hasTag("loading")) {
return <FullScreenLoader />
} else {
Expand Down
4 changes: 2 additions & 2 deletions site/src/components/SignIn/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const SignInForm: React.FC<SignInFormProps> = ({ isLoading, authErrorMess
<form onSubmit={form.handleSubmit}>
<div>
<FormTextField
label="Email"
autoComplete="email"
autoFocus
className={styles.loginTextField}
Expand All @@ -71,10 +72,10 @@ export const SignInForm: React.FC<SignInFormProps> = ({ isLoading, authErrorMess
inputProps={{
id: "signin-form-inpt-email",
}}
placeholder="Email"
variant="outlined"
/>
<FormTextField
label="Password"
autoComplete="current-password"
className={styles.loginTextField}
form={form}
Expand All @@ -84,7 +85,6 @@ export const SignInForm: React.FC<SignInFormProps> = ({ isLoading, authErrorMess
id: "signin-form-inpt-password",
}}
isPassword
placeholder="Password"
variant="outlined"
/>
{authErrorMessage && (
Expand Down
12 changes: 2 additions & 10 deletions site/src/pages/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { useActor } from "@xstate/react"
import React, { useContext } from "react"
import { SignInForm } from "./../components/SignIn"
import { Navigate, useLocation } from "react-router-dom"
import { Location } from "history"
import { XServiceContext } from "../xServices/StateContext"
import { retrieveRedirect } from "../util/redirect"

export const useStyles = makeStyles((theme) => ({
root: {
Expand All @@ -20,21 +20,13 @@ export const useStyles = makeStyles((theme) => ({
},
}))

const getRedirectFromLocation = (location: Location) => {
const defaultRedirect = "/"

const searchParams = new URLSearchParams(location.search)
const redirect = searchParams.get("redirect")
return redirect ? redirect : defaultRedirect
}

export const SignInPage: React.FC = () => {
const styles = useStyles()
const location = useLocation()
const xServices = useContext(XServiceContext)
const [userState, userSend] = useActor(xServices.userXService)
const isLoading = userState.hasTag("loading")
const redirectTo = getRedirectFromLocation(location)
const redirectTo = retrieveRedirect(location.search)
const authErrorMessage = userState.context.authError ? (userState.context.authError as Error).message : undefined

const onSubmit = async ({ email, password }: { email: string; password: string }) => {
Expand Down
24 changes: 12 additions & 12 deletions site/src/test_helpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,39 @@ export const MockSessionToken = { session_token: "my-session-token" }
export const MockAPIKey = { key: "my-api-key" }

export const MockUser: UserResponse = {
id: "test-user-id",
id: "test-user",
username: "TestUser",
email: "test@coder.com",
created_at: "",
}

export const MockProject: Project = {
id: "project-id",
export const MockOrganization: Organization = {
id: "test-org",
name: "Test Organization",
created_at: "",
updated_at: "",
organization_id: "test-org",
name: "Test Project",
provisioner: "test-provisioner",
active_version_id: "",
}

export const MockProvisioner: Provisioner = {
id: "test-provisioner",
name: "Test Provisioner",
}

export const MockOrganization: Organization = {
id: "test-org",
name: "Test Organization",
export const MockProject: Project = {
id: "test-project",
created_at: "",
updated_at: "",
organization_id: MockOrganization.id,
name: "Test Project",
provisioner: MockProvisioner.id,
active_version_id: "",
}

export const MockWorkspace: Workspace = {
id: "test-workspace",
name: "Test-Workspace",
created_at: "",
updated_at: "",
project_id: "project-id",
owner_id: "test-user-id",
project_id: MockProject.id,
owner_id: MockUser.id,
}
22 changes: 22 additions & 0 deletions site/src/test_helpers/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,26 @@ import { rest } from "msw"
import * as M from "./entities"

export const handlers = [
// organizations
rest.get("/api/v2/organizations/:organizationId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockOrganization))
}),
rest.get("/api/v2/organizations/:organizationId/projects/:projectId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockProject))
}),

// projects
rest.get("/api/v2/projects/:projectId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockProject))
}),

// users
rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspace))
}),
rest.get("/api/v2/users/me/organizations/:organizationId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockOrganization))
}),
rest.post("/api/v2/users/login", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockSessionToken))
}),
Expand All @@ -17,4 +34,9 @@ export const handlers = [
rest.get("/api/v2/users/me/keys", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockAPIKey))
}),

// workspaces
rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspace))
}),
]
20 changes: 20 additions & 0 deletions site/src/util/redirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { embedRedirect, retrieveRedirect } from "./redirect"

describe("redirect helper functions", () => {
describe("embedRedirect", () => {
it("embeds the page to return to in the URL", () => {
const result = embedRedirect("/workspaces", "/page")
expect(result).toEqual("/page?redirect=%2Fworkspaces")
})
it("defaults to navigating to the login page", () => {
const result = embedRedirect("/workspaces")
expect(result).toEqual("/login?redirect=%2Fworkspaces")
})
})
describe("retrieveRedirect", () => {
it("retrieves the page to return to from the URL", () => {
const result = retrieveRedirect("?redirect=%2Fworkspaces")
expect(result).toEqual("/workspaces")
})
})
})

0 comments on commit f2ac81c

Please sign in to comment.