Skip to content

Commit

Permalink
GCW-3432 Authentication routing (#6)
Browse files Browse the repository at this point in the history
* GCW-3432 Remove routing logic

* GCW-3432 Create component for main routing

Essential for testing routing by allowing us to mock router history

* GCW-3432 Move MainRouter to its own directory

* GCW-3432 Test for presence of ion-router-outlet

Ionic uses this to handle animations related to navigation/routing

* GCW-3432 Move router outlet to MainRouter

Component required by Ionic so rather not have to mock this out in tests

* GCW-3432 Install history

* GCW-3432 Test navigation to /home url

* GCW-3432 Use Router instead of IonReactRouter

Components do not show up as expected on JSDOM with IonReactRouter

* GCW-3432 Show the home page

* GCW-3432 Create login page and routing

* GCW-3432 Refactor tests

* GCW-3432 Remove test in incorrect location

Test belongs to MainRouter

* GCW-3432 Add missing assertion in test

* GCW-3432 Add assertion for ion-router-outlet

* GCW-3432 Make assertions more explicitly

* GCW-3432 Create context for authentication state

* GCW-3432 Route users based on auth state

* GCW-3432 Downgrade history version

Latest version has compatibility issues esp. redirects

* GCW-3432 Test redirect normally without spyOn

Reverting history ver. to 4 fixes the issue with redirects

* GCW-3432 Redirect user from root path

Routing should be based on user auth status

* GCW-3432 Add title to login page

* GCW-3432 Test without resorting to data-testid

Rely on actual DOM elements for testing rather than data-testid

* GCW-3432 Change file naming convention

index.ts convention makes it harder to distinguish tabs in editors

* GCW-3432 Put Login into its own folder

* GCW-3432 Move MainRouter to components folder

* GCW-3432 Create login button

* GCW-3432 Simplify test element selectors

* GCW-3432 Create login functionality

* GCW-3432 Update ion router outlet test

Don't rely on data-testid

* GCW-3432 Encapsulate auth access in custom hook

* GCW-3432 Install TS + add type checking to tests

Reference: facebook/create-react-app#5626

* GCW-3432 Encapsulate auth state in Provider

* GCW-3432 Update tests to use AuthProvider

* GCW-3432 Wrap app with AuthProvider

* GCW-3432 Fix type error in test

* GCW-3432 Refactor tests

* GCW-3432 Refactor test expectations to function

* GCW-3432 Refactor tests

Change function signature of render function to allow for currying

* GCW-3432 Refactor tests

* GCW-3432 Route from non-existent page (unauthed)

* GCW-3432 Route from non-existent page (authed)

* GCW-3432 Remove redundant route

* GCW-3432 Handle visiting page with /login prefix

* GCW-3432 Handle visiting page with /home prefix

* GCW-3432 Encapsulate private route logic

* GCW-3432 Refactor login tests

* GCW-3432 Redirect user to home on login

* GCW-3432 Increase test clarity

* GCW-3432 Move expectation fn to test utils
  • Loading branch information
BboyStatix committed Dec 8, 2020
1 parent f823bbf commit dd7c56e
Show file tree
Hide file tree
Showing 13 changed files with 301 additions and 18 deletions.
30 changes: 30 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,18 @@
"@types/react-router-dom": "^5.1.3",
"cordova-plugin-ionic": "5.4.7",
"cordova-plugin-whitelist": "^1.3.4",
"history": "^4.10.1",
"ionicons": "^5.0.0",
"react": "^16.13.0",
"react-dom": "^16.13.0",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.0",
"typescript": "3.8.3"
"react-scripts": "3.4.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"test": "tsc && react-scripts test",
"eject": "react-scripts eject",
"cap:sync": "cap sync",
"format": "prettier --ignore-path .gitignore --write ."
Expand All @@ -53,6 +53,7 @@
"description": "An Ionic project",
"devDependencies": {
"@capacitor/cli": "2.4.2",
"prettier": "2.2.0"
"prettier": "2.2.0",
"typescript": "^3.8.3"
}
}
27 changes: 14 additions & 13 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from "react";
import { Redirect, Route } from "react-router-dom";
import { IonApp, IonRouterOutlet } from "@ionic/react";
import { IonApp } from "@ionic/react";
import { IonReactRouter } from "@ionic/react-router";
import Home from "./pages/Home";
import MainRouter from "./components/MainRouter/MainRouter";
import AuthProvider from "./components/AuthProvider";

/* Core CSS required for Ionic components to work properly */
import "@ionic/react/css/core.css";
Expand All @@ -23,15 +23,16 @@ import "@ionic/react/css/display.css";
/* Theme variables */
import "./theme/variables.css";

const App: React.FC = () => (
<IonApp>
<IonReactRouter>
<IonRouterOutlet>
<Route path="/home" component={Home} exact={true} />
<Route exact path="/" render={() => <Redirect to="/home" />} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
);
const App: React.FC = () => {
return (
<IonApp>
<AuthProvider>
<IonReactRouter>
<MainRouter />
</IonReactRouter>
</AuthProvider>
</IonApp>
);
};

export default App;
20 changes: 20 additions & 0 deletions src/components/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React, { useState } from "react";
import AuthContext from "../context/AuthContext";

interface Props {
initialAuthState?: boolean;
}
const AuthProvider: React.FC<Props> = ({
children,
initialAuthState = false,
}) => {
const [isAuthenticated, setIsAuthenticated] = useState(initialAuthState);

return (
<AuthContext.Provider value={{ isAuthenticated, setIsAuthenticated }}>
{children}
</AuthContext.Provider>
);
};

export default AuthProvider;
61 changes: 61 additions & 0 deletions src/components/MainRouter/MainRouter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React from "react";
import { render } from "@testing-library/react";
import MainRouter from "./MainRouter";
import { createMemoryHistory } from "history";
import { Router } from "react-router";
import AuthProvider from "../../components/AuthProvider";
import { expectToBeOnPage } from "../../test-utils/matchers";

const renderComponent = (initialAuthState: boolean) => (
initialPath: string
) => {
const history = createMemoryHistory({ initialEntries: [initialPath] });
return {
history,
...render(
<AuthProvider initialAuthState={initialAuthState}>
<Router history={history}>
<MainRouter />
</Router>
</AuthProvider>
),
};
};

describe("Unauthenticated User", () => {
const renderUnauthenticatedComponent = renderComponent(false);

[
{ initialPath: "/home", expectedPage: "login" },
{ initialPath: "/login", expectedPage: "login" },
{ initialPath: "/", expectedPage: "login" },
{ initialPath: "/bad-route", expectedPage: "login" },
{ initialPath: "/home/bad-route", expectedPage: "login" },
{ initialPath: "/login/bad-route", expectedPage: "login" },
].map(({ initialPath, expectedPage }) => {
it(`visiting ${initialPath} should be taken to ${expectedPage}`, () => {
const { container, history } = renderUnauthenticatedComponent(
initialPath
);
expectToBeOnPage(container, history.location.pathname, expectedPage);
});
});
});

describe("Authenticated User", () => {
const renderAuthenticatedComponent = renderComponent(true);

[
{ initialPath: "/home", expectedPage: "home" },
{ initialPath: "/login", expectedPage: "login" },
{ initialPath: "/", expectedPage: "home" },
{ initialPath: "/bad-route", expectedPage: "home" },
{ initialPath: "/home/bad-route", expectedPage: "home" },
{ initialPath: "/login/bad-route", expectedPage: "home" },
].map(({ initialPath, expectedPage }) => {
it(`visiting ${initialPath} should be taken to ${expectedPage}`, () => {
const { container, history } = renderAuthenticatedComponent(initialPath);
expectToBeOnPage(container, history.location.pathname, expectedPage);
});
});
});
24 changes: 24 additions & 0 deletions src/components/MainRouter/MainRouter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from "react";
import { Redirect, Route, Switch } from "react-router";
import Home from "../../pages/Home";
import Login from "../../pages/Login/Login";
import useAuth from "../../hooks/useAuth";
import PrivateRoute from "../../components/PrivateRoute";

const MainRouter: React.FC = () => {
const { isAuthenticated } = useAuth();

return (
<Switch>
<Route exact path="/login">
<Login />
</Route>
<PrivateRoute exact path="/home">
<Home />
</PrivateRoute>
<Redirect to={isAuthenticated ? "/home" : "/login"} />
</Switch>
);
};

export default MainRouter;
19 changes: 19 additions & 0 deletions src/components/PrivateRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from "react";
import { Redirect, Route } from "react-router";
import useAuth from "../hooks/useAuth";

interface RestProps {
exact: boolean;
path: string;
}
const PrivateRoute: React.FC<RestProps> = ({ children, ...rest }) => {
const { isAuthenticated } = useAuth();

return (
<Route {...rest}>
{isAuthenticated ? children : <Redirect to="/login" />}
</Route>
);
};

export default PrivateRoute;
13 changes: 13 additions & 0 deletions src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from "react";

interface ContextProps {
isAuthenticated: boolean;
setIsAuthenticated: React.Dispatch<React.SetStateAction<boolean>>;
}

export const AuthContext = React.createContext<ContextProps>({
isAuthenticated: false,
setIsAuthenticated: () => {},
});

export default AuthContext;
6 changes: 6 additions & 0 deletions src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { useContext } from "react";
import AuthContext from "../context/AuthContext";

const useAuth = () => useContext(AuthContext);

export default useAuth;
2 changes: 1 addition & 1 deletion src/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import React from "react";

const Home: React.FC = () => {
return (
<IonPage>
<IonPage title="home">
<IonHeader>
<IonToolbar>
<IonTitle>Home</IonTitle>
Expand Down
58 changes: 58 additions & 0 deletions src/pages/Login/Login.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from "react";
import { render } from "@testing-library/react";
import Login from "./Login";
import userEvent, { TargetElement } from "@testing-library/user-event";
import AuthContext from "../../context/AuthContext";
import AuthProvider from "../../components/AuthProvider";
import ReactRouter, { MemoryRouter } from "react-router";

test("renders a login title", () => {
const { container } = render(<Login />);
expect(container.querySelector("ion-title")).toHaveTextContent(/login/i);
});

test("renders a login button", () => {
const { container } = render(<Login />);
expect(container.querySelector("ion-button")).toHaveTextContent(/login/i);
});

test("clicking the login button logs user in", () => {
const mockSetIsAuthenticated = jest.fn();
const { container } = render(
<AuthContext.Provider
value={{
isAuthenticated: false,
setIsAuthenticated: mockSetIsAuthenticated,
}}
>
<MemoryRouter initialEntries={["/login"]}>
<Login />
</MemoryRouter>
</AuthContext.Provider>
);

userEvent.click(container.querySelector("ion-button") as TargetElement);

expect(mockSetIsAuthenticated).toHaveBeenCalledWith(true);
expect(mockSetIsAuthenticated).toHaveBeenCalledTimes(1);
});

test("user is redirected to home page on login", () => {
const mockHistory = { replace: jest.fn() };
const mockUseHistory = jest
.spyOn(ReactRouter, "useHistory")
.mockReturnValue(mockHistory as any);

const { container } = render(
<AuthProvider>
<MemoryRouter initialEntries={["/login"]}>
<Login />
</MemoryRouter>
</AuthProvider>
);
userEvent.click(container.querySelector("ion-button") as TargetElement);

expect(mockHistory.replace).toHaveBeenCalledWith("/home");

mockUseHistory.mockRestore();
});
36 changes: 36 additions & 0 deletions src/pages/Login/Login.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
IonButton,
IonContent,
IonHeader,
IonPage,
IonTitle,
IonToolbar,
} from "@ionic/react";
import React from "react";
import { useHistory } from "react-router";
import useAuth from "../../hooks/useAuth";

const Login: React.FC = () => {
const { setIsAuthenticated } = useAuth();
const history = useHistory();

const handleLogin = () => {
setIsAuthenticated(true);
history.replace("/home");
};

return (
<IonPage title="login">
<IonHeader>
<IonToolbar>
<IonTitle>Login</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<IonButton onClick={handleLogin}>Login</IonButton>
</IonContent>
</IonPage>
);
};

export default Login;
14 changes: 14 additions & 0 deletions src/test-utils/matchers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const expectToBeOnPage = (
container: HTMLElement,
myPath: string,
expectedPage: string
) => {
const expectedPath = `/${expectedPage}`;
expect(myPath).toEqual(expectedPath);
expect(container.querySelector(".ion-page")).toHaveAttribute(
"title",
expectedPage
);
};

export { expectToBeOnPage };

0 comments on commit dd7c56e

Please sign in to comment.