Finished testing implementation

This commit is contained in:
Christopher Sanden
2026-03-18 16:38:55 +01:00
parent c73363efb9
commit 7aad9dc34d
15 changed files with 3177 additions and 33 deletions

View File

@@ -26,4 +26,15 @@
**or**
- Run the app in an emulator from the Expo developer tools
#Running tests
Run the Jest test suite from the project root:
```bash
npm test
```
Run a single test file:
```bash
npx jest __tests__/detail-screen.test.tsx
```

View File

@@ -0,0 +1,54 @@
import { renderRouter, screen, waitFor } from "expo-router/testing-library"
import React from "react"
jest.mock("@/providers/auth-provider", () => {
const React = require("react")
const { AuthContext } = require("@/hooks/use-auth-context")
return {
__esModule: true,
default: ({ children }: React.PropsWithChildren) =>
React.createElement(
AuthContext.Provider,
{
value: {
claims: null,
profile: null,
isLoading: false,
isLoggedIn: false,
},
},
children
),
}
})
jest.mock("@/libs/supabase", () => ({
supabase: {
auth: {
signInWithPassword: jest.fn(),
signUp: jest.fn(),
signOut: jest.fn(),
getUser: jest.fn(),
},
from: jest.fn(),
},
supabaseUrl: "https://example.supabase.co",
supabaseAnonKey: "test-anon-key",
}))
describe("Auth guard", () => {
it("redirects logged-out users to the login screen instead of protected content", async () => {
const routerScreen = renderRouter("./app", {
initialUrl: "/",
})
await waitFor(() => {
expect(routerScreen.getPathname()).toBe("/login")
})
expect(screen.getByText("Login")).toBeTruthy()
expect(screen.getByText("Log in")).toBeTruthy()
expect(screen.queryByText("FastNotes")).toBeNull()
})
})

View File

@@ -0,0 +1,178 @@
import { act, render, screen, waitFor } from "@testing-library/react-native"
import React, { PropsWithChildren } from "react"
import DetailScreen from "@/app/detail"
import { AuthContext, AuthData } from "@/hooks/use-auth-context"
import { supabase } from "@/libs/supabase"
import { NotesProvider } from "@/src/notes/NotesContext"
import { AppThemeProvider } from "@/src/theme/AppThemeProvider"
import { useLocalSearchParams } from "expo-router"
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve
reject = innerReject
})
return { promise, resolve, reject }
}
jest.mock("expo-router", () => ({
router: {
replace: jest.fn(),
back: jest.fn(),
canGoBack: jest.fn(),
},
useLocalSearchParams: jest.fn(),
}))
jest.mock("@/libs/supabase", () => ({
supabase: {
from: jest.fn(),
},
supabaseUrl: "https://example.supabase.co",
supabaseAnonKey: "test-anon-key",
}))
jest.mock("@/components/note-image-panel", () => ({
__esModule: true,
default: () => null,
}))
describe("DetailScreen", () => {
const mockUseLocalSearchParams = useLocalSearchParams as jest.MockedFunction<typeof useLocalSearchParams>
const mockSupabase = supabase as unknown as {
from: jest.Mock
}
function TestWrapper({ children }: PropsWithChildren) {
const authValue: AuthData = {
claims: {
sub: "user-1",
email: "user-1@example.com",
},
profile: {
id: "user-1",
email: "user-1@example.com",
username: null,
full_name: null,
},
isLoading: false,
isLoggedIn: true,
}
return (
<AppThemeProvider>
<AuthContext.Provider value={authValue}>
<NotesProvider>{children}</NotesProvider>
</AuthContext.Provider>
</AppThemeProvider>
)
}
beforeEach(() => {
mockUseLocalSearchParams.mockReturnValue({ id: "42" })
})
it("shows a loader while fetching a note, then renders the loaded content", async () => {
const deferredNote = createDeferred<{
data: {
id: number
created_by: string
title: string
content: string
created_at: string
updated_at: string
image_url: null
image_path: null
image_mime_type: null
image_size_bytes: null
} | null
error: null
}>()
const notesQuery = {
order: jest.fn(),
eq: jest.fn(),
maybeSingle: jest.fn(() => deferredNote.promise),
}
notesQuery.order
.mockImplementationOnce(() => notesQuery)
.mockImplementationOnce(() => Promise.resolve({ data: [], error: null }))
notesQuery.eq.mockReturnValue(notesQuery)
mockSupabase.from.mockImplementation((table: string) => {
if (table === "Notes") {
return {
select: jest.fn(() => notesQuery),
}
}
if (table === "profiles") {
return {
select: jest.fn(() => ({
in: jest.fn(() =>
Promise.resolve({
data: [
{
id: "user-1",
email: "user-1@example.com",
username: null,
full_name: "Exam User",
},
],
error: null,
})
),
})),
}
}
throw new Error(`Unexpected table requested in test: ${table}`)
})
render(<DetailScreen />, {
wrapper: TestWrapper,
})
await waitFor(() => {
expect(screen.getByTestId("note-detail-loader")).toBeTruthy()
expect(screen.getByText("Loading note...")).toBeTruthy()
})
await act(async () => {
deferredNote.resolve({
data: {
id: 42,
created_by: "user-1",
title: "Fetched note",
content: "Loaded from Supabase for the integration test",
created_at: "2026-03-18T10:00:00.000Z",
updated_at: "2026-03-18T10:05:00.000Z",
image_url: null,
image_path: null,
image_mime_type: null,
image_size_bytes: null,
},
error: null,
})
await deferredNote.promise
})
await waitFor(() => {
expect(screen.queryByTestId("note-detail-loader")).toBeNull()
expect(screen.getByDisplayValue("Fetched note")).toBeTruthy()
expect(screen.getByDisplayValue("Loaded from Supabase for the integration test")).toBeTruthy()
})
})
})

View File

@@ -0,0 +1,77 @@
import { fireEvent, screen, waitFor } from "@testing-library/react-native"
import React from "react"
import NewNoteScreen from "@/app/newNote"
import { useNotes } from "@/src/notes/NotesContext"
import { renderWithTheme } from "@/test-utils/renderWithTheme"
import { router } from "expo-router"
const mockAddNote = jest.fn()
jest.mock("expo-router", () => ({
router: {
canGoBack: jest.fn(),
back: jest.fn(),
replace: jest.fn(),
},
}))
jest.mock("@/src/notes/NotesContext", () => ({
useNotes: jest.fn(),
}))
jest.mock("@/components/note-image-panel", () => ({
__esModule: true,
default: () => null,
}))
describe("NewNoteScreen", () => {
const mockUseNotes = useNotes as jest.MockedFunction<typeof useNotes>
const mockRouter = router as unknown as {
canGoBack: jest.Mock
back: jest.Mock
replace: jest.Mock
}
beforeEach(() => {
mockAddNote.mockResolvedValue(true)
mockUseNotes.mockReturnValue({
notes: [],
isLoading: false,
refreshNotes: jest.fn(),
fetchNoteById: jest.fn(),
addNote: mockAddNote,
updateNote: jest.fn(),
deleteNote: jest.fn(),
errorMessage: null,
})
mockRouter.canGoBack.mockReturnValue(true)
})
it("submits a valid note and navigates back to the main screen", async () => {
renderWithTheme(<NewNoteScreen />)
fireEvent.changeText(screen.getByPlaceholderText("Give it a title..."), "Exam note")
fireEvent.changeText(screen.getByPlaceholderText("Write your note..."), "Testing the final assignment flow")
fireEvent.press(screen.getByText("Save note"))
await waitFor(() => {
expect(mockAddNote).toHaveBeenCalledWith(
"Exam note",
"Testing the final assignment flow",
null,
expect.objectContaining({
onImageUploadProgress: expect.any(Function),
})
)
})
await waitFor(() => {
expect(mockRouter.back).toHaveBeenCalledTimes(1)
})
expect(mockRouter.replace).not.toHaveBeenCalled()
})
})

View File

@@ -91,10 +91,11 @@ function ThemedRootLayout() {
function NotificationProviderGate({ children }: PropsWithChildren) {
const isAndroidExpoGo = Platform.OS === "android" && Constants.executionEnvironment === "storeClient"
const isTestEnvironment = process.env.NODE_ENV === "test"
const [provider, setProvider] = useState<ComponentType<PropsWithChildren> | null>(null)
useEffect(() => {
if (isAndroidExpoGo) {
if (isAndroidExpoGo || isTestEnvironment) {
return
}
@@ -117,9 +118,9 @@ function NotificationProviderGate({ children }: PropsWithChildren) {
return () => {
isMounted = false
}
}, [isAndroidExpoGo])
}, [isAndroidExpoGo, isTestEnvironment])
if (isAndroidExpoGo || !provider) {
if (isAndroidExpoGo || isTestEnvironment || !provider) {
return children
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"
import {
Alert,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
@@ -28,7 +29,7 @@ export default function DetailScreen() {
id?: string
}>()
const { claims } = useAuthContext()
const { deleteNote, errorMessage, notes, updateNote } = useNotes()
const { deleteNote, errorMessage, fetchNoteById, notes, updateNote } = useNotes()
const note = notes.find((entry) => entry.id === id)
const canEdit = note?.createdBy === claims?.sub
const [title, setTitle] = useState(note?.title ?? "")
@@ -40,6 +41,7 @@ export default function DetailScreen() {
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [isLoadingNote, setIsLoadingNote] = useState(false)
const insets = useSafeAreaInsets()
const headerHeight = useHeaderHeight()
const { colorScheme, palette } = useAppTheme()
@@ -61,6 +63,27 @@ export default function DetailScreen() {
setImageChange({ type: "keep" })
}, [note?.content, note?.id, note?.title])
useEffect(() => {
if (!id || note) {
setIsLoadingNote(false)
return
}
let isMounted = true
setIsLoadingNote(true)
void fetchNoteById(id).finally(() => {
if (isMounted) {
setIsLoadingNote(false)
}
})
return () => {
isMounted = false
}
}, [fetchNoteById, id, note])
const attachFromCamera = async () => {
try {
const image = await pickImageFromCamera()
@@ -171,6 +194,18 @@ export default function DetailScreen() {
}
}
if (isLoadingNote && !note) {
return (
<View
testID="note-detail-loader"
style={[styles.container, { backgroundColor: palette.background, padding: 16, justifyContent: "center" }]}
>
<ActivityIndicator size="large" color={palette.accent} />
<Text style={[styles.content, { color: palette.mutedText, marginTop: 12 }]}>Loading note...</Text>
</View>
)
}
if (!note) {
return (
<View style={[styles.container, { backgroundColor: palette.background, padding: 16 }]}>

12
FastNotes/jest.config.js Normal file
View File

@@ -0,0 +1,12 @@
module.exports = {
preset: "jest-expo",
clearMocks: true,
watchman: false,
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/$1",
},
transformIgnorePatterns: [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo/.*|expo-router|@react-navigation/.*|react-native-safe-area-context|react-native-reanimated|react-native-gesture-handler|react-native-screens))",
],
};

128
FastNotes/jest.setup.ts Normal file
View File

@@ -0,0 +1,128 @@
import "react-native-gesture-handler/jestSetup"
import { cleanup } from "@testing-library/react-native"
import mockSafeAreaContext from "react-native-safe-area-context/jest/mock"
afterEach(() => {
cleanup()
})
jest.mock("@react-native-async-storage/async-storage", () =>
require("@react-native-async-storage/async-storage/jest/async-storage-mock")
)
jest.mock("expo-constants", () => {
const mockConstants = {
expoConfig: {
extra: {
supabaseUrl: "https://example.supabase.co",
supabaseKey: "test-anon-key",
},
},
executionEnvironment: "standalone",
}
return {
__esModule: true,
default: mockConstants,
...mockConstants,
}
})
jest.mock("react-native-safe-area-context", () => mockSafeAreaContext)
jest.mock("react-native-reanimated", () => {
const Reanimated = require("react-native-reanimated/mock")
Reanimated.default.call = () => {}
return Reanimated
})
jest.mock("react-native-screens", () => {
const React = require("react")
const { View } = require("react-native")
const MockScreen = ({ children }: { children?: unknown }) => React.createElement(View, null, children)
return {
__esModule: true,
default: MockScreen,
compatibilityFlags: {
usesNewAndroidHeaderHeightImplementation: false,
},
Screen: MockScreen,
ScreenContainer: MockScreen,
ScreenStack: MockScreen,
ScreenStackItem: MockScreen,
ScreenStackHeaderConfig: MockScreen,
ScreenStackHeaderBackButtonImage: MockScreen,
ScreenStackHeaderCenterView: MockScreen,
ScreenStackHeaderLeftView: MockScreen,
ScreenStackHeaderRightView: MockScreen,
ScreenStackHeaderSearchBarView: MockScreen,
SearchBar: MockScreen,
NativeScreen: MockScreen,
NativeScreenContainer: MockScreen,
FullWindowOverlay: MockScreen,
Freeze: MockScreen,
enableScreens: jest.fn(),
isSearchBarAvailableForCurrentPlatform: jest.fn(() => false),
screensEnabled: jest.fn(),
}
})
jest.mock("expo-blur", () => {
const React = require("react")
const { View } = require("react-native")
return {
BlurView: ({ children, style }: { children?: unknown; style?: object }) =>
React.createElement(View, { style }, children),
}
})
jest.mock("expo-image", () => {
const React = require("react")
const { View } = require("react-native")
return {
Image: ({ style, testID }: { style?: object; testID?: string }) =>
React.createElement(View, { style, testID: testID ?? "mock-expo-image" }),
}
})
jest.mock("expo-image-picker", () => ({
CameraType: {
back: "back",
},
getMediaLibraryPermissionsAsync: jest.fn(async () => ({ granted: true })),
requestMediaLibraryPermissionsAsync: jest.fn(async () => ({ granted: true })),
launchImageLibraryAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
getCameraPermissionsAsync: jest.fn(async () => ({ granted: true })),
requestCameraPermissionsAsync: jest.fn(async () => ({ granted: true })),
launchCameraAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
}))
jest.mock("expo-image-manipulator", () => ({
SaveFormat: {
PNG: "png",
WEBP: "webp",
JPEG: "jpeg",
},
manipulateAsync: jest.fn(async (uri: string) => ({ uri })),
}))
jest.mock("@react-navigation/elements", () => {
const actual = jest.requireActual("@react-navigation/elements")
return {
...actual,
useHeaderHeight: () => 0,
}
})
jest.mock("@/src/notifications/PushNotificationsProvider", () => ({
__esModule: true,
default: ({ children }: { children?: unknown }) => children,
}))
jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper", () => ({}), { virtual: true })

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,11 @@
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint"
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"typecheck:functions": "deno check --config supabase/functions/deno.json supabase/functions/push/index.ts",
"test": "jest --runInBand",
"test:watch": "jest --watch"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
@@ -50,9 +54,14 @@
"supabase-js": "^0.0.1-security"
},
"devDependencies": {
"@testing-library/react-native": "^13.2.0",
"@types/jest": "^29.5.14",
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"jest": "29.7.0",
"jest-expo": "~54.0.17",
"react-test-renderer": "19.1.0",
"typescript": "~5.9.2"
},
"private": true

View File

@@ -49,6 +49,7 @@ type NotesContextValue = {
isLoading: boolean
errorMessage: string | null
refreshNotes: () => Promise<void>
fetchNoteById: (noteId: string) => Promise<Note | null>
addNote: (
title: string,
content: string,
@@ -94,7 +95,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
userId ||
"Unknown user"
const buildCreatorLabels = async (rows: NoteRow[]) => {
const buildCreatorLabels = useCallback(async (rows: NoteRow[]) => {
const creatorIds = Array.from(new Set(rows.map((row) => row.created_by)))
if (creatorIds.length === 0) {
@@ -119,7 +120,23 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
return acc
}, {})
}
}, [])
const mapNoteRow = useCallback((row: NoteRow, labels: Record<string, string>) => ({
id: String(row.id),
createdBy: row.created_by,
createdAt: row.created_at,
lastChangedAt: row.updated_at || row.created_at,
title: row.title,
content: row.content,
creatorLabel:
labels[row.created_by] ||
(row.created_by === userId ? creatorLabel : "Unknown user"),
imageUrl: row.image_url ?? null,
imagePath: row.image_path ?? null,
imageMimeType: row.image_mime_type ?? null,
imageSizeBytes: normalizeImageSizeBytes(row.image_size_bytes),
}), [creatorLabel, userId])
const loadNotes = useCallback(async () => {
if (!isLoggedIn) {
@@ -151,29 +168,51 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
const labels = await buildCreatorLabels(rows)
setNotes(
rows.map((row) => ({
id: String(row.id),
createdBy: row.created_by,
createdAt: row.created_at,
lastChangedAt: row.updated_at || row.created_at,
title: row.title,
content: row.content,
creatorLabel:
labels[row.created_by] ||
(row.created_by === userId ? creatorLabel : "Unknown user"),
imageUrl: row.image_url ?? null,
imagePath: row.image_path ?? null,
imageMimeType: row.image_mime_type ?? null,
imageSizeBytes: normalizeImageSizeBytes(row.image_size_bytes),
}))
rows.map((row) => mapNoteRow(row, labels))
)
setIsLoading(false)
}, [creatorLabel, isLoggedIn, userId])
}, [buildCreatorLabels, isLoggedIn, mapNoteRow])
const refreshNotes = async () => {
await loadNotes()
}
const fetchNoteById = useCallback(async (noteId: string) => {
if (!isLoggedIn || !noteId) {
return null
}
setErrorMessage(null)
const { data, error } = await supabase
.from("Notes")
.select(
"id, created_by, title, content, created_at, updated_at, image_url, image_path, image_mime_type, image_size_bytes"
)
.eq("id", Number(noteId))
.maybeSingle()
if (error) {
setErrorMessage(error.message)
return null
}
if (!data) {
return null
}
const row = data as NoteRow
const labels = await buildCreatorLabels([row])
const fetchedNote = mapNoteRow(row, labels)
setNotes((prev) => {
const nextNotes = prev.filter((existingNote) => existingNote.id !== fetchedNote.id)
return [fetchedNote, ...nextNotes]
})
return fetchedNote
}, [buildCreatorLabels, isLoggedIn, mapNoteRow])
useEffect(() => {
if (!isLoggedIn || !userId) {
setNotes([])
@@ -433,6 +472,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
isLoading,
errorMessage,
refreshNotes,
fetchNoteById,
addNote,
updateNote,
deleteNote,

View File

@@ -0,0 +1,6 @@
{
"compilerOptions": {
"strict": true,
"lib": ["deno.ns", "dom", "esnext"]
}
}

View File

@@ -10,6 +10,14 @@ type NoteRecord = {
title: string
}
type ProfileEmailRow = {
email: string | null
}
type PushTokenRow = {
push_token: string | null
}
type DatabaseWebhookPayload = {
type: "INSERT" | "UPDATE" | "DELETE"
table: string
@@ -49,15 +57,17 @@ function chunkMessages<T>(items: T[], size: number) {
return chunks
}
async function loadCreatorEmail(userId: string) {
async function loadCreatorEmail(userId: string): Promise<string> {
const { data: profile } = await supabase
.from("profiles")
.select("email")
.eq("id", userId)
.maybeSingle()
if (profile?.email) {
return profile.email as string
const typedProfile = profile as ProfileEmailRow | null
if (typedProfile?.email) {
return typedProfile.email
}
const { data, error } = await supabase.auth.admin.getUserById(userId)
@@ -70,7 +80,7 @@ async function loadCreatorEmail(userId: string) {
return data.user.email ?? "unknown user"
}
async function loadRecipientTokens(userId: string) {
async function loadRecipientTokens(userId: string): Promise<string[]> {
const { data, error } = await supabase
.from("user_push_tokens")
.select("push_token")
@@ -81,7 +91,9 @@ async function loadRecipientTokens(userId: string) {
throw new Error(error.message)
}
return Array.from(new Set((data ?? []).map((row) => row.push_token as string).filter(Boolean)))
const rows = (data ?? []) as PushTokenRow[]
return Array.from(new Set(rows.map((row) => row.push_token).filter((token): token is string => Boolean(token))))
}
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
@@ -108,7 +120,7 @@ async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
}
}
Deno.serve(async (request) => {
Deno.serve(async (request: Request) => {
if (request.method !== "POST") {
return jsonResponse(405, { error: "Method not allowed" })
}
@@ -141,7 +153,7 @@ Deno.serve(async (request) => {
}
const body = `New note: "${note.title}" by ${creatorEmail}`
const messages: ExpoPushMessage[] = recipientTokens.map((token) => ({
const messages: ExpoPushMessage[] = recipientTokens.map((token: string) => ({
to: token,
title: "FastNotes",
body,

View File

@@ -0,0 +1,15 @@
import { render, RenderOptions } from "@testing-library/react-native"
import React, { PropsWithChildren, ReactElement } from "react"
import { AppThemeProvider } from "@/src/theme/AppThemeProvider"
function ThemeWrapper({ children }: PropsWithChildren) {
return <AppThemeProvider>{children}</AppThemeProvider>
}
export function renderWithTheme(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) {
return render(ui, {
wrapper: ThemeWrapper,
...options,
})
}

View File

@@ -13,5 +13,8 @@
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
],
"exclude": [
"supabase/functions/**/*"
]
}