1
FastNotes/.vscode/extensions.json
vendored
1
FastNotes/.vscode/extensions.json
vendored
@@ -1 +0,0 @@
|
|||||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
|
||||||
7
FastNotes/.vscode/settings.json
vendored
7
FastNotes/.vscode/settings.json
vendored
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll": "explicit",
|
|
||||||
"source.organizeImports": "explicit",
|
|
||||||
"source.sortMembers": "explicit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,4 +26,17 @@
|
|||||||
**or**
|
**or**
|
||||||
- Run the app in an emulator from the Expo developer tools
|
- 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 each test file one by one:
|
||||||
|
```bash
|
||||||
|
npx jest __tests__/detail-screen.test.tsx
|
||||||
|
npx jest __tests__/auth-guard.test.tsx
|
||||||
|
npx jest __tests__/new-note.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
54
FastNotes/__tests__/auth-guard.test.tsx
Normal file
54
FastNotes/__tests__/auth-guard.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
178
FastNotes/__tests__/detail-screen.test.tsx
Normal file
178
FastNotes/__tests__/detail-screen.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
77
FastNotes/__tests__/new-note.test.tsx
Normal file
77
FastNotes/__tests__/new-note.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -91,10 +91,11 @@ function ThemedRootLayout() {
|
|||||||
|
|
||||||
function NotificationProviderGate({ children }: PropsWithChildren) {
|
function NotificationProviderGate({ children }: PropsWithChildren) {
|
||||||
const isAndroidExpoGo = Platform.OS === "android" && Constants.executionEnvironment === "storeClient"
|
const isAndroidExpoGo = Platform.OS === "android" && Constants.executionEnvironment === "storeClient"
|
||||||
|
const isTestEnvironment = process.env.NODE_ENV === "test"
|
||||||
const [provider, setProvider] = useState<ComponentType<PropsWithChildren> | null>(null)
|
const [provider, setProvider] = useState<ComponentType<PropsWithChildren> | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAndroidExpoGo) {
|
if (isAndroidExpoGo || isTestEnvironment) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +118,9 @@ function NotificationProviderGate({ children }: PropsWithChildren) {
|
|||||||
return () => {
|
return () => {
|
||||||
isMounted = false
|
isMounted = false
|
||||||
}
|
}
|
||||||
}, [isAndroidExpoGo])
|
}, [isAndroidExpoGo, isTestEnvironment])
|
||||||
|
|
||||||
if (isAndroidExpoGo || !provider) {
|
if (isAndroidExpoGo || isTestEnvironment || !provider) {
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -28,7 +29,7 @@ export default function DetailScreen() {
|
|||||||
id?: string
|
id?: string
|
||||||
}>()
|
}>()
|
||||||
const { claims } = useAuthContext()
|
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 note = notes.find((entry) => entry.id === id)
|
||||||
const canEdit = note?.createdBy === claims?.sub
|
const canEdit = note?.createdBy === claims?.sub
|
||||||
const [title, setTitle] = useState(note?.title ?? "")
|
const [title, setTitle] = useState(note?.title ?? "")
|
||||||
@@ -40,6 +41,7 @@ export default function DetailScreen() {
|
|||||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||||
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
|
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
|
||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||||
|
const [isLoadingNote, setIsLoadingNote] = useState(false)
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const headerHeight = useHeaderHeight()
|
const headerHeight = useHeaderHeight()
|
||||||
const { colorScheme, palette } = useAppTheme()
|
const { colorScheme, palette } = useAppTheme()
|
||||||
@@ -61,6 +63,27 @@ export default function DetailScreen() {
|
|||||||
setImageChange({ type: "keep" })
|
setImageChange({ type: "keep" })
|
||||||
}, [note?.content, note?.id, note?.title])
|
}, [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 () => {
|
const attachFromCamera = async () => {
|
||||||
try {
|
try {
|
||||||
const image = await pickImageFromCamera()
|
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) {
|
if (!note) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: palette.background, padding: 16 }]}>
|
<View style={[styles.container, { backgroundColor: palette.background, padding: 16 }]}>
|
||||||
|
|||||||
12
FastNotes/jest.config.js
Normal file
12
FastNotes/jest.config.js
Normal 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
128
FastNotes/jest.setup.ts
Normal 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 })
|
||||||
2666
FastNotes/package-lock.json
generated
2666
FastNotes/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,11 @@
|
|||||||
"android": "expo start --android",
|
"android": "expo start --android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo start --ios",
|
||||||
"web": "expo start --web",
|
"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": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
@@ -18,6 +22,7 @@
|
|||||||
"@react-navigation/native": "^7.1.28",
|
"@react-navigation/native": "^7.1.28",
|
||||||
"@supabase/supabase-js": "^2.98.0",
|
"@supabase/supabase-js": "^2.98.0",
|
||||||
"async-storage": "^0.1.0",
|
"async-storage": "^0.1.0",
|
||||||
|
"deno": "^2.7.6",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-blur": "~15.0.8",
|
"expo-blur": "~15.0.8",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
@@ -50,9 +55,14 @@
|
|||||||
"supabase-js": "^0.0.1-security"
|
"supabase-js": "^0.0.1-security"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react-native": "^13.2.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.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"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type NotesContextValue = {
|
|||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
errorMessage: string | null
|
errorMessage: string | null
|
||||||
refreshNotes: () => Promise<void>
|
refreshNotes: () => Promise<void>
|
||||||
|
fetchNoteById: (noteId: string) => Promise<Note | null>
|
||||||
addNote: (
|
addNote: (
|
||||||
title: string,
|
title: string,
|
||||||
content: string,
|
content: string,
|
||||||
@@ -94,7 +95,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
userId ||
|
userId ||
|
||||||
"Unknown user"
|
"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)))
|
const creatorIds = Array.from(new Set(rows.map((row) => row.created_by)))
|
||||||
|
|
||||||
if (creatorIds.length === 0) {
|
if (creatorIds.length === 0) {
|
||||||
@@ -119,7 +120,23 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return acc
|
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 () => {
|
const loadNotes = useCallback(async () => {
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
@@ -151,29 +168,51 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const labels = await buildCreatorLabels(rows)
|
const labels = await buildCreatorLabels(rows)
|
||||||
|
|
||||||
setNotes(
|
setNotes(
|
||||||
rows.map((row) => ({
|
rows.map((row) => mapNoteRow(row, labels))
|
||||||
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),
|
|
||||||
}))
|
|
||||||
)
|
)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}, [creatorLabel, isLoggedIn, userId])
|
}, [buildCreatorLabels, isLoggedIn, mapNoteRow])
|
||||||
|
|
||||||
const refreshNotes = async () => {
|
const refreshNotes = async () => {
|
||||||
await loadNotes()
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn || !userId) {
|
if (!isLoggedIn || !userId) {
|
||||||
setNotes([])
|
setNotes([])
|
||||||
@@ -433,6 +472,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
isLoading,
|
isLoading,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
refreshNotes,
|
refreshNotes,
|
||||||
|
fetchNoteById,
|
||||||
addNote,
|
addNote,
|
||||||
updateNote,
|
updateNote,
|
||||||
deleteNote,
|
deleteNote,
|
||||||
|
|||||||
6
FastNotes/supabase/functions/deno.json
Normal file
6
FastNotes/supabase/functions/deno.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"lib": ["deno.ns", "dom", "esnext"]
|
||||||
|
}
|
||||||
|
}
|
||||||
79
FastNotes/supabase/functions/deno.lock
generated
Normal file
79
FastNotes/supabase/functions/deno.lock
generated
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"specifiers": {
|
||||||
|
"npm:@supabase/supabase-js@2": "2.99.2"
|
||||||
|
},
|
||||||
|
"npm": {
|
||||||
|
"@supabase/auth-js@2.99.2": {
|
||||||
|
"integrity": "sha512-uRGNXMKEw4VhwouNW7N0XDAGqJP9redHNDmWi17dTrcO1lvFfyRiXsqqfgnVC8aqtRn8kLkLPEzHjiRWsni+oQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"tslib"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@supabase/functions-js@2.99.2": {
|
||||||
|
"integrity": "sha512-xuXQARvjdfB1UPK1yUceZ5EGjOLkVz4rBAaloS9foXiAuseWEdgWBCxkIAFRxGBLGX8Uzo8kseq90jhPb+07Vg==",
|
||||||
|
"dependencies": [
|
||||||
|
"tslib"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@supabase/postgrest-js@2.99.2": {
|
||||||
|
"integrity": "sha512-ueiOVkbkTQ7RskwVmjR8zxWYw3VKOMxo1+qep+Dx/SgApqyhWBGd92waQb45tbLc7ydB5x8El8utXOLQTuTojQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"tslib"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@supabase/realtime-js@2.99.2": {
|
||||||
|
"integrity": "sha512-J6Jm9601dkpZf3+EJ48ki2pM4sFtCNm/BI0l8iEnrczgg+JSEQkMoOW5VSpM54t0pNs69bsz5PTmYJahDZKiIQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/phoenix",
|
||||||
|
"@types/ws",
|
||||||
|
"tslib",
|
||||||
|
"ws"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@supabase/storage-js@2.99.2": {
|
||||||
|
"integrity": "sha512-V/FF8kX8JGSefsVCG1spCLSrHdNR/JFeUMn1jS9KG/Eizjx+evtdKQKLJXFgIylY/bKTXKhc2SYDPIGrIhzsug==",
|
||||||
|
"dependencies": [
|
||||||
|
"iceberg-js",
|
||||||
|
"tslib"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@supabase/supabase-js@2.99.2": {
|
||||||
|
"integrity": "sha512-179rn5wq0wBAqqGwAwR7TUGg2NOaP+fkd5FCVbYJXby85fsRNPFoNJN8YRBepqX2tN7JJcnTjqaAMXuNjiyisA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@supabase/auth-js",
|
||||||
|
"@supabase/functions-js",
|
||||||
|
"@supabase/postgrest-js",
|
||||||
|
"@supabase/realtime-js",
|
||||||
|
"@supabase/storage-js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@types/node@25.5.0": {
|
||||||
|
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||||
|
"dependencies": [
|
||||||
|
"undici-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@types/phoenix@1.6.7": {
|
||||||
|
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="
|
||||||
|
},
|
||||||
|
"@types/ws@8.18.1": {
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"iceberg-js@0.8.1": {
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="
|
||||||
|
},
|
||||||
|
"tslib@2.8.1": {
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||||
|
},
|
||||||
|
"undici-types@7.18.2": {
|
||||||
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="
|
||||||
|
},
|
||||||
|
"ws@8.19.0": {
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
|
// deno-lint-ignore no-import-prefix
|
||||||
import { createClient } from "npm:@supabase/supabase-js@2"
|
import { createClient } from "npm:@supabase/supabase-js@2"
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""
|
|
||||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""
|
|
||||||
const EXPO_ACCESS_TOKEN = Deno.env.get("EXPO_ACCESS_TOKEN") ?? ""
|
|
||||||
|
|
||||||
type NoteRecord = {
|
type NoteRecord = {
|
||||||
id: number | string
|
id: number | string
|
||||||
created_by: string
|
created_by: string
|
||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProfileEmailRow = {
|
||||||
|
email: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushTokenRow = {
|
||||||
|
push_token: string | null
|
||||||
|
}
|
||||||
|
|
||||||
type DatabaseWebhookPayload = {
|
type DatabaseWebhookPayload = {
|
||||||
type: "INSERT" | "UPDATE" | "DELETE"
|
type: "INSERT" | "UPDATE" | "DELETE"
|
||||||
table: string
|
table: string
|
||||||
@@ -30,7 +35,62 @@ type ExpoPushMessage = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
|
type ExpoPushTicket = {
|
||||||
|
status?: "ok" | "error"
|
||||||
|
id?: string
|
||||||
|
message?: string
|
||||||
|
details?: {
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExpoPushResponse = {
|
||||||
|
data?: ExpoPushTicket[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExpoSendResult = {
|
||||||
|
acceptedCount: number
|
||||||
|
failedTickets: Array<{
|
||||||
|
token: string
|
||||||
|
error: string
|
||||||
|
}>
|
||||||
|
invalidTokens: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupabaseAdminClient = ReturnType<typeof createClient>
|
||||||
|
|
||||||
|
let supabase: SupabaseAdminClient | null = null
|
||||||
|
|
||||||
|
function getOptionalEnv(name: string) {
|
||||||
|
return Deno.env.get(name)?.trim() ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEnv(name: string) {
|
||||||
|
const value = getOptionalEnv(name)
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required env var: ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupabaseClient() {
|
||||||
|
if (!supabase) {
|
||||||
|
supabase = createClient(
|
||||||
|
requireEnv("SUPABASE_URL"),
|
||||||
|
requireEnv("SUPABASE_SERVICE_ROLE_KEY"),
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
persistSession: false,
|
||||||
|
autoRefreshToken: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return supabase
|
||||||
|
}
|
||||||
|
|
||||||
function jsonResponse(status: number, body: Record<string, unknown>) {
|
function jsonResponse(status: number, body: Record<string, unknown>) {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
@@ -49,15 +109,21 @@ function chunkMessages<T>(items: T[], size: number) {
|
|||||||
return chunks
|
return chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCreatorEmail(userId: string) {
|
async function loadCreatorEmail(supabase: SupabaseAdminClient, userId: string): Promise<string> {
|
||||||
const { data: profile } = await supabase
|
const { data: profile, error: profileError } = await supabase
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select("email")
|
.select("email")
|
||||||
.eq("id", userId)
|
.eq("id", userId)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
|
|
||||||
if (profile?.email) {
|
if (profileError) {
|
||||||
return profile.email as string
|
console.error("Failed to load note creator profile:", profileError.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedProfile = profile as ProfileEmailRow | null
|
||||||
|
|
||||||
|
if (typedProfile?.email?.trim()) {
|
||||||
|
return typedProfile.email
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.admin.getUserById(userId)
|
const { data, error } = await supabase.auth.admin.getUserById(userId)
|
||||||
@@ -70,7 +136,7 @@ async function loadCreatorEmail(userId: string) {
|
|||||||
return data.user.email ?? "unknown user"
|
return data.user.email ?? "unknown user"
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRecipientTokens(userId: string) {
|
async function loadRecipientTokens(supabase: SupabaseAdminClient, userId: string): Promise<string[]> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("user_push_tokens")
|
.from("user_push_tokens")
|
||||||
.select("push_token")
|
.select("push_token")
|
||||||
@@ -81,17 +147,55 @@ async function loadRecipientTokens(userId: string) {
|
|||||||
throw new Error(error.message)
|
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[]) {
|
async function deactivatePushTokens(supabase: SupabaseAdminClient, tokens: string[]) {
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
const pushTokensTable = supabase.from("user_push_tokens") as any
|
||||||
|
|
||||||
|
const { error } = await pushTokensTable
|
||||||
|
.update({
|
||||||
|
is_active: false,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.in("push_token", tokens)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Failed to deactivate invalid push tokens:", error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExpoPushResponse(responseText: string): ExpoPushResponse {
|
||||||
|
try {
|
||||||
|
return JSON.parse(responseText) as ExpoPushResponse
|
||||||
|
} catch {
|
||||||
|
throw new Error("Expo push response was not valid JSON.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendExpoPushNotifications(
|
||||||
|
supabase: SupabaseAdminClient,
|
||||||
|
messages: ExpoPushMessage[],
|
||||||
|
): Promise<ExpoSendResult> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
if (EXPO_ACCESS_TOKEN) {
|
const invalidTokens = new Set<string>()
|
||||||
headers.Authorization = `Bearer ${EXPO_ACCESS_TOKEN}`
|
const failedTickets: ExpoSendResult["failedTickets"] = []
|
||||||
|
let acceptedCount = 0
|
||||||
|
const expoAccessToken = getOptionalEnv("EXPO_ACCESS_TOKEN")
|
||||||
|
|
||||||
|
if (expoAccessToken) {
|
||||||
|
headers.Authorization = `Bearer ${expoAccessToken}`
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const chunk of chunkMessages(messages, 100)) {
|
for (const chunk of chunkMessages(messages, 100)) {
|
||||||
@@ -101,27 +205,69 @@ async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
|
|||||||
body: JSON.stringify(chunk),
|
body: JSON.stringify(chunk),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const responseText = await response.text()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorBody = await response.text()
|
throw new Error(`Expo push request failed with ${response.status}: ${responseText}`)
|
||||||
throw new Error(`Expo push request failed with ${response.status}: ${errorBody}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const responseBody = parseExpoPushResponse(responseText)
|
||||||
|
const tickets = Array.isArray(responseBody.data) ? responseBody.data : []
|
||||||
|
|
||||||
|
if (tickets.length !== chunk.length) {
|
||||||
|
throw new Error(`Expo push response size mismatch: expected ${chunk.length} tickets, got ${tickets.length}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < tickets.length; index += 1) {
|
||||||
|
const ticket = tickets[index]
|
||||||
|
const token = chunk[index].to
|
||||||
|
|
||||||
|
if (ticket?.status === "ok") {
|
||||||
|
acceptedCount += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const expoError = ticket?.details?.error
|
||||||
|
const errorMessage = ticket?.message ?? "Unknown Expo push ticket error."
|
||||||
|
failedTickets.push({
|
||||||
|
token,
|
||||||
|
error: expoError ? `${expoError}: ${errorMessage}` : errorMessage,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (expoError === "DeviceNotRegistered") {
|
||||||
|
invalidTokens.add(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokensToDeactivate = Array.from(invalidTokens)
|
||||||
|
await deactivatePushTokens(supabase, tokensToDeactivate)
|
||||||
|
|
||||||
|
return {
|
||||||
|
acceptedCount,
|
||||||
|
failedTickets,
|
||||||
|
invalidTokens: tokensToDeactivate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
Deno.serve(async (request: Request) => {
|
||||||
if (request.method !== "POST") {
|
if (request.method !== "POST") {
|
||||||
return jsonResponse(405, { error: "Method not allowed" })
|
return jsonResponse(405, { error: "Method not allowed" })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
|
|
||||||
return jsonResponse(500, { error: "Missing Supabase environment variables." })
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: DatabaseWebhookPayload
|
let payload: DatabaseWebhookPayload
|
||||||
|
let supabase: SupabaseAdminClient
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
supabase = getSupabaseClient()
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unexpected error."
|
||||||
|
|
||||||
|
if (message.startsWith("Missing required env var:")) {
|
||||||
|
return jsonResponse(500, { error: message })
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse(400, { error: "Invalid JSON payload." })
|
return jsonResponse(400, { error: "Invalid JSON payload." })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,8 +278,8 @@ Deno.serve(async (request) => {
|
|||||||
try {
|
try {
|
||||||
const note = payload.record
|
const note = payload.record
|
||||||
const [creatorEmail, recipientTokens] = await Promise.all([
|
const [creatorEmail, recipientTokens] = await Promise.all([
|
||||||
loadCreatorEmail(note.created_by),
|
loadCreatorEmail(supabase, note.created_by),
|
||||||
loadRecipientTokens(note.created_by),
|
loadRecipientTokens(supabase, note.created_by),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (recipientTokens.length === 0) {
|
if (recipientTokens.length === 0) {
|
||||||
@@ -141,7 +287,7 @@ Deno.serve(async (request) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = `New note: "${note.title}" by ${creatorEmail}`
|
const body = `New note: "${note.title}" by ${creatorEmail}`
|
||||||
const messages: ExpoPushMessage[] = recipientTokens.map((token) => ({
|
const messages: ExpoPushMessage[] = recipientTokens.map((token: string) => ({
|
||||||
to: token,
|
to: token,
|
||||||
title: "FastNotes",
|
title: "FastNotes",
|
||||||
body,
|
body,
|
||||||
@@ -153,9 +299,17 @@ Deno.serve(async (request) => {
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
await sendExpoPushNotifications(messages)
|
const sendResult = await sendExpoPushNotifications(supabase, messages)
|
||||||
|
|
||||||
return jsonResponse(200, { sent: messages.length })
|
if (sendResult.failedTickets.length > 0) {
|
||||||
|
console.error("Expo rejected one or more push messages:", sendResult.failedTickets)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(200, {
|
||||||
|
sent: sendResult.acceptedCount,
|
||||||
|
failed: sendResult.failedTickets.length,
|
||||||
|
deactivated: sendResult.invalidTokens.length,
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unexpected error."
|
const message = error instanceof Error ? error.message : "Unexpected error."
|
||||||
console.error("Push notification webhook failed:", message)
|
console.error("Push notification webhook failed:", message)
|
||||||
|
|||||||
15
FastNotes/test-utils/renderWithTheme.tsx
Normal file
15
FastNotes/test-utils/renderWithTheme.tsx
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,5 +13,8 @@
|
|||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".expo/types/**/*.ts",
|
".expo/types/**/*.ts",
|
||||||
"expo-env.d.ts"
|
"expo-env.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"supabase/functions/**/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user