Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .env.example

This file was deleted.

1 change: 1 addition & 0 deletions apps/mobile/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
194 changes: 173 additions & 21 deletions apps/mobile/__tests__/auth-flow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,198 @@
import { fireEvent, render } from "@testing-library/react-native";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { beforeEach, describe, expect, it, jest } from "@jest/globals";

import { CreateAccountScreen } from "../features/auth/CreateAccountScreen";
import { LoginScreen } from "../features/auth/LoginScreen";
import { HomeScreen } from "../features/home/HomeScreen";
import { supabase } from "../lib/supabase";

describe("local authentication prototype", () => {
it("opens the home screen after the user submits a username and password", () => {
const onLogin = jest.fn();
const screen = render(
jest.mock("../lib/supabase", () => ({
supabase: {
rpc: jest.fn(),
auth: {
signUp: jest.fn(),
signInWithPassword: jest.fn(),
signOut: jest.fn(),
},
},
}));

// Loosely typed so the mocks accept any resolved value
type MockFn = jest.Mock<(...args: unknown[]) => Promise<unknown>>;

const mockSupabase = supabase as unknown as {
rpc: MockFn;
auth: {
signUp: MockFn;
signInWithPassword: MockFn;
signOut: MockFn;
};
};

beforeEach(() => {
jest.clearAllMocks();

// username_available -> true, get_email_for_username -> an email
mockSupabase.rpc.mockImplementation((fn: unknown) =>
Promise.resolve({
data: fn === "username_available" ? true : "person@example.com",
error: null,
}),
);
mockSupabase.auth.signUp.mockResolvedValue({
data: { session: {} },
error: null,
});
mockSupabase.auth.signInWithPassword.mockResolvedValue({ error: null });
mockSupabase.auth.signOut.mockResolvedValue({ error: null });
});

describe("login", () => {
async function submitLogin(onLogin = jest.fn()) {
const screen = await render(
<LoginScreen onLogin={onLogin} onCreateAccount={jest.fn()} />,
);

fireEvent.changeText(screen.getByLabelText("Username"), "quickcop_user");
fireEvent.changeText(screen.getByLabelText("Password"), "password123");
fireEvent.press(screen.getByRole("button", { name: "Log in" }));
await fireEvent.changeText(
screen.getByLabelText("Username"),
"quickcop_user",
);
await fireEvent.changeText(
screen.getByLabelText("Password"),
"password123",
);
await fireEvent.press(screen.getByRole("button", { name: "Log in" }));

return { screen, onLogin };
}

it("looks up the email, signs in, and opens the home screen", async () => {
const { onLogin } = await submitLogin();

await waitFor(() => expect(onLogin).toHaveBeenCalledTimes(1));
expect(mockSupabase.rpc).toHaveBeenCalledWith("get_email_for_username", {
p_username: "quickcop_user",
});
expect(mockSupabase.auth.signInWithPassword).toHaveBeenCalledWith({
email: "person@example.com",
password: "password123",
});
});

it("shows an error when the password is wrong", async () => {
mockSupabase.auth.signInWithPassword.mockResolvedValue({
error: { message: "Invalid login credentials" },
});

const { screen, onLogin } = await submitLogin();

await waitFor(() =>
expect(screen.getByText("Invalid username or password.")).toBeTruthy(),
);
expect(onLogin).not.toHaveBeenCalled();
});

it("shows the same error when the username does not exist", async () => {
mockSupabase.rpc.mockResolvedValue({ data: null, error: null });

const { screen, onLogin } = await submitLogin();

expect(onLogin).toHaveBeenCalledTimes(1);
await waitFor(() =>
expect(screen.getByText("Invalid username or password.")).toBeTruthy(),
);
expect(mockSupabase.auth.signInWithPassword).not.toHaveBeenCalled();
expect(onLogin).not.toHaveBeenCalled();
});
});

it("allows account creation with an email and no phone number", () => {
const onAccountCreated = jest.fn();
const screen = render(
describe("create account", () => {
async function submitSignUp(onAccountCreated = jest.fn()) {
const screen = await render(
<CreateAccountScreen
onAccountCreated={onAccountCreated}
onBackToLogin={jest.fn()}
/>,
);

fireEvent.changeText(screen.getByLabelText("Email"), "person@example.com");
fireEvent.changeText(screen.getByLabelText("Username"), "quickcop_user");
fireEvent.changeText(screen.getByLabelText("Password"), "password123");
fireEvent.press(screen.getByRole("button", { name: "Create account" }));
await fireEvent.changeText(
screen.getByLabelText("Email"),
"person@example.com",
);
await fireEvent.changeText(
screen.getByLabelText("Username"),
"quickcop_user",
);
await fireEvent.changeText(
screen.getByLabelText("Password"),
"password123",
);
await fireEvent.press(
screen.getByRole("button", { name: "Create account" }),
);

return { screen, onAccountCreated };
}

it("allows account creation with an email and no phone number", async () => {
const { onAccountCreated } = await submitSignUp();

await waitFor(() => expect(onAccountCreated).toHaveBeenCalledTimes(1));
expect(mockSupabase.auth.signUp).toHaveBeenCalledWith({
email: "person@example.com",
password: "password123",
options: {
data: {
username: "quickcop_user",
display_name: "quickcop_user",
phone: null,
},
},
});
});

it("rejects a taken username without creating an account", async () => {
mockSupabase.rpc.mockResolvedValue({ data: false, error: null });

const { screen, onAccountCreated } = await submitSignUp();

await waitFor(() =>
expect(screen.getByText("That username is taken.")).toBeTruthy(),
);
expect(mockSupabase.auth.signUp).not.toHaveBeenCalled();
expect(onAccountCreated).not.toHaveBeenCalled();
});

it("asks the user to confirm their email when no session is returned", async () => {
mockSupabase.auth.signUp.mockResolvedValue({
data: { session: null },
error: null,
});

const { screen, onAccountCreated } = await submitSignUp();

expect(onAccountCreated).toHaveBeenCalledTimes(1);
await waitFor(() =>
expect(
screen.getByText(
"Check your email to confirm your account, then log in.",
),
).toBeTruthy(),
);
expect(onAccountCreated).not.toHaveBeenCalled();
});
});

it("returns to login when the user logs out from the empty home screen", () => {
describe("logout", () => {
it("signs out and returns to login from the home menu", async () => {
const onLogout = jest.fn();
const screen = render(<HomeScreen onLogout={onLogout} />);
const screen = await render(
<HomeScreen onLogout={onLogout} onOpenNotifications={jest.fn()} />,
);

fireEvent.press(screen.getByRole("button", { name: "Log out" }));
// The Log out button lives inside the hamburger menu
await fireEvent.press(screen.getByRole("button", { name: "Open menu" }));
await fireEvent.press(screen.getByRole("button", { name: "Log out" }));

expect(onLogout).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onLogout).toHaveBeenCalledTimes(1));
expect(mockSupabase.auth.signOut).toHaveBeenCalledTimes(1);
});
});
1 change: 1 addition & 0 deletions apps/mobile/__tests__/notifications-screen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fireEvent, render } from "@testing-library/react-native";
import { describe, it, expect } from "@jest/globals";

import {
NotificationsScreen,
Expand Down
31 changes: 27 additions & 4 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
import { Stack } from "expo-router";
import { Stack, useRouter, useSegments } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";

import { AuthProvider, useAuth } from "../features/auth/AuthProvider";

function RootNavigator() {
const { session, loading } = useAuth();
const segments = useSegments();
const router = useRouter();

useEffect(() => {
if (loading) return;

const onHome = segments[0] === "home";

if (session && !onHome) {
router.replace("/home");
} else if (!session && onHome) {
router.replace("/");
}
}, [session, loading, segments]);

return <Stack screenOptions={{ headerShown: false }} />;
}

export default function RootLayout() {
return (
<>
<AuthProvider>
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }} />
</>
<RootNavigator />
</AuthProvider>
);
}
42 changes: 42 additions & 0 deletions apps/mobile/features/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Session } from "@supabase/supabase-js";
import { createContext, useContext, useEffect, useState } from "react";

import { supabase } from "../../lib/supabase";

type AuthContextType = {
session: Session | null;
loading: boolean;
};

const AuthContext = createContext<AuthContextType>({
session: null,
loading: true,
});

export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setSession(data.session);
setLoading(false);
});

const { data: listener } = supabase.auth.onAuthStateChange(
(_event, newSession) => {
setSession(newSession);
},
);

return () => listener.subscription.unsubscribe();
}, []);

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

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