Add some tests

This commit is contained in:
ponas committed 2026-07-26 01:58:22 +02:00
1 parent 9e2cb13bed
commit 35227d43de
23 files changed
+2737 -104

No files matched your search

+20 -1
View File
@@ -1,5 +1,6 @@
module.exports = {
root: true,
ignorePatterns: ['coverage/'],
extends: [
'@react-native',
'airbnb-base',
@@ -29,7 +30,15 @@ module.exports = {
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: ['**/*.test.js', '**/*.spec.js', '**/__tests__/**'],
devDependencies: [
'**/*.test.{js,jsx,ts,tsx}',
'**/*.spec.{js,jsx,ts,tsx}',
'**/__tests__/**',
'src/testUtils.tsx',
'jest.config.js',
'jest.setup.js',
'__mocks__/**',
],
},
],
'import/prefer-default-export': 'off',
@@ -62,5 +71,15 @@ module.exports = {
extends: ['plugin:@typescript-eslint/disable-type-checked'],
files: ['./**/*.js'],
},
{
// Jest infrastructure is CommonJS by nature
files: ['jest.config.js', 'jest.setup.js', '__mocks__/**'],
rules: {
'global-require': 'off',
'@typescript-eslint/no-var-requires': 'off',
'import/extensions': 'off',
'class-methods-use-this': 'off',
},
},
],
};
+2
View File
@@ -9,6 +9,7 @@ dist/
web-build/
android/
ios/
credentials.json
# Native
*.orig.*
@@ -35,3 +36,4 @@ yarn-error.*
# typescript
*.tsbuildinfo
coverage/
+16 -1
View File
@@ -28,6 +28,8 @@ Install [Expo Go](https://expo.dev/client) to run the app on your phone.
- `npm run lint` - Check code with ESLint
- `npm run format` - Fix code formatting with ESLint
- `npm run check-types` - Run TypeScript type checking
- `npm test` - Run the test suite
- `npm run test:watch` - Run the test suite in watch mode
## History
@@ -41,7 +43,20 @@ history, code and binary blobs related to React Native functionality filtered ou
- privacy policy
- delete my account
- ???
- add some basic unit tests
- test in android emulator? (`npm run android`)
- test in ios emulator? (`npm run ios`, requires macOS)
- use [Expo Application Services](https://expo.dev/eas) to build and submit app to stores
- replace redux (`redux` v5 + `redux-thunk`, hand-written actions and the deprecated
`createStore`): nearly everything in the store is server state (user data) plus one auth
token, so drop redux for [TanStack Query](https://tanstack.com/query/latest) for
fetching/caching, with the token in React context + AsyncStorage — replaces
`src/reducers.ts`, the fetch thunks in `src/api.ts` and the `isFetching*`/error flags
- replace [moment](https://momentjs.com/docs/#/-project-status/) (maintenance mode) with
`date-fns`
- replace unmaintained UI packages:
- `react-native-confetti` (last release 2017, mocked out in tests)
- `react-native-simple-radio-button` (react-native-ui-lib already ships RadioButton/RadioGroup)
- remove unused dependencies: `styled-system`, `react-native-render-html`,
`metro-react-native-babel-preset`
- migrate ESLint 8 + airbnb config (both EOL) to ESLint 9 flat config or
[eslint-config-expo](https://docs.expo.dev/guides/using-eslint/)
+3
View File
@@ -0,0 +1,3 @@
// Stand-in for SVG files imported as components (react-native-svg-transformer).
module.exports = 'SvgMock';
module.exports.ReactComponent = 'SvgMock';
+18
View File
@@ -0,0 +1,18 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'jest-expo',
setupFiles: ['react-native-gesture-handler/jestSetup', '<rootDir>/jest.setup.js'],
moduleNameMapper: {
// Metro resolves SVGs to components via react-native-svg-transformer; Jest gets a stub instead
'\\.svg$': '<rootDir>/__mocks__/svgMock.js',
// Absolute imports relative to the repo root (tsconfig baseUrl)
'^src/(.*)$': '<rootDir>/src/$1',
'^assets/(.*)$': '<rootDir>/assets/$1',
},
transformIgnorePatterns: [
// jest-expo's default list, extended with untranspiled libraries this app uses
'node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|react-native-ui-lib|uilib-native|react-redux))',
'/node_modules/react-native-reanimated/plugin/',
],
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.test.{ts,tsx}', '!src/types/**', '!src/testUtils.tsx'],
};
+58
View File
@@ -0,0 +1,58 @@
/* eslint-disable no-undef */
process.env.EXPO_PUBLIC_EVENT_API_URL = 'https://events.example.test/api/v1/events';
process.env.EXPO_PUBLIC_DUSKEN_API_URL = 'https://dusken.example.test';
process.env.EXPO_PUBLIC_DUSKEN_FORGOT_PASSWORD_URL = 'https://dusken.example.test/auth/password_reset/';
process.env.EXPO_PUBLIC_DUSKEN_PRIVACY_POLICY_URL = 'https://dusken.example.test/nb/personvern/';
process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY = 'pk_test_fake';
// No test may hit the network. Individual tests override this (or mock
// fetchWithTimeout in src/utils) when they need specific responses.
global.fetch = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ events: [] }),
}));
// react-native-ui-lib calls StatusBarManager.getHeight at import time,
// which the react-native jest mock does not provide.
const { NativeModules } = require('react-native');
NativeModules.StatusBarManager = {
HEIGHT: 42,
...NativeModules.StatusBarManager,
getHeight: (callback) => callback({ height: 42 }),
};
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);
jest.mock('@stripe/stripe-react-native', () => require('@stripe/stripe-react-native/jest/mock.js'));
jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'));
// jest-expo stubs the native side of expo-notifications with functions that
// resolve to undefined, which crashes destructuring like `const { status } = ...`.
jest.mock('expo-notifications', () => ({
SchedulableTriggerInputTypes: { DATE: 'date' },
setNotificationHandler: jest.fn(),
getPermissionsAsync: jest.fn(async () => ({ status: 'undetermined' })),
requestPermissionsAsync: jest.fn(async () => ({ status: 'denied' })),
cancelAllScheduledNotificationsAsync: jest.fn(async () => {}),
scheduleNotificationAsync: jest.fn(async () => 'notification-id'),
}));
// Visual only, and old enough that it fights with the test renderer.
jest.mock('react-native-confetti', () => {
const React = require('react');
return class Confetti extends React.Component {
startConfetti() {}
stopConfetti() {}
render() {
return null;
}
};
});
+2304 -12
View File
File diff suppressed because it is too large. Load diff
+9 -3
View File
@@ -10,10 +10,12 @@
"ios": "expo run:ios",
"lint": "eslint .",
"start": "expo start --port 19001",
"test": "jest",
"test:watch": "jest --watchAll",
"web": "expo start --web"
},
"dependencies": {
"@apollo/client": "^3.10.8",
"@apollo/client": "^3.14.1",
"@babel/plugin-transform-private-methods": "^7.24.7",
"@expo/vector-icons": "^14.1.0",
"@react-native-async-storage/async-storage": "2.1.2",
@@ -54,7 +56,8 @@
"devDependencies": {
"@babel/core": "^7.24.9",
"@react-native/eslint-config": "^0.74.85",
"@types/jest": "^29.5.12",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^29.5.14",
"@types/react": "~19.1.12",
"@types/react-native-confetti": "^0.1.4",
"@types/react-native-simple-radio-button": "^2.7.7",
@@ -69,7 +72,10 @@
"eslint-plugin-react": "^7.34.3",
"eslint-plugin-react-hooks": "^4.6.2",
"expo-dev-client": "~5.0.20",
"jest": "^29.7.0",
"jest-expo": "~53.0.10",
"prettier": "^3.3.3",
"typescript": "~5.3.3"
"react-test-renderer": "19.0.0",
"typescript": "~5.8.3"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { render, waitFor } from '@testing-library/react-native';
import React from 'react';
import App from 'src/App';
it('renders the whole app without crashing', async () => {
const view = render(<App />);
await waitFor(() => expect(view.toJSON()).toBeTruthy());
});
-10
View File
@@ -1,10 +0,0 @@
import 'react-native';
import { render } from '@testing-library/react-native';
import React from 'react';
import App from 'src/App';
it('renders correctly', () => {
const { toJSON } = render(<App />);
expect(toJSON()).toBeTruthy();
});
+44 -40
View File
@@ -1,66 +1,70 @@
import * as Notifications from 'expo-notifications';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
export const useNotifications = () => {
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
useEffect(() => {
const checkPermission = async () => {
const { status } = await Notifications.getPermissionsAsync();
setHasPermission(status === 'granted');
};
checkPermission();
}, []);
const checkPermission = async () => {
const { status } = await Notifications.getPermissionsAsync();
setHasPermission(status === 'granted');
};
const requestPermission = async () => {
const requestPermission = useCallback(async () => {
const { status } = await Notifications.requestPermissionsAsync();
const granted = status === 'granted';
setHasPermission(granted);
return granted;
};
}, []);
const scheduleMembershipExpiryNotification = async (expiryDate: string) => {
if (!hasPermission) {
const granted = await requestPermission();
if (!granted) return;
}
const scheduleMembershipExpiryNotification = useCallback(
async (expiryDate: string) => {
if (!hasPermission) {
const granted = await requestPermission();
if (!granted) return;
}
// Cancel any existing notifications before scheduling a new one
await Notifications.cancelAllScheduledNotificationsAsync();
// Cancel any existing notifications before scheduling a new one
await Notifications.cancelAllScheduledNotificationsAsync();
const expiryDateObj = new Date(expiryDate);
const notificationDate = new Date(expiryDateObj);
notificationDate.setDate(notificationDate.getDate() + 1); // 1 day after expiry
const expiryDateObj = new Date(expiryDate);
const notificationDate = new Date(expiryDateObj);
notificationDate.setDate(notificationDate.getDate() + 1); // 1 day after expiry
// Don't schedule if the notification date is in the past
if (notificationDate <= new Date()) return;
// Don't schedule if the notification date is in the past
if (notificationDate <= new Date()) return;
await Notifications.scheduleNotificationAsync({
content: {
title: 'Medlemskapet ditt har utløpt!',
body: 'Ditt medlemskap i Det Norske Studentersamfund har utløpt! Vi håper du ønsker å fornye det :)',
data: { type: 'membership_expiry' },
},
trigger: {
type: 'date',
date: notificationDate,
},
});
};
await Notifications.scheduleNotificationAsync({
content: {
title: 'Medlemskapet ditt har utløpt!',
body: 'Ditt medlemskap i Det Norske Studentersamfund har utløpt! Vi håper du ønsker å fornye det :)',
data: { type: 'membership_expiry' },
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: notificationDate,
},
});
},
[hasPermission, requestPermission],
);
const toggleNotifications = async (enabled: boolean) => {
if (enabled) {
// When enabling, request permission
const granted = await requestPermission();
return { success: granted, needsPermission: !granted };
} else {
const toggleNotifications = useCallback(
async (enabled: boolean) => {
if (enabled) {
// When enabling, request permission
const granted = await requestPermission();
return { success: granted, needsPermission: !granted };
}
// When disabling, cancel any existing notifications and update state
await Notifications.cancelAllScheduledNotificationsAsync();
setHasPermission(false);
return { success: true, needsPermission: false };
}
};
},
[requestPermission],
);
return {
hasPermission,
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import About from 'src/modules/about/About';
import { renderWithProviders } from 'src/testUtils';
it('renders the about screen', () => {
const { getByText } = renderWithProviders(<About />);
expect(getByText('Åpningstider')).toBeTruthy();
expect(getByText('Ta kontakt')).toBeTruthy();
});
+47
View File
@@ -0,0 +1,47 @@
import React from 'react';
import EventList from 'src/modules/events/EventList';
import { renderWithProviders } from 'src/testUtils';
import { fetchWithTimeout } from 'src/utils';
jest.mock('src/utils', () => ({
...jest.requireActual('src/utils'),
fetchWithTimeout: jest.fn(),
}));
const mockFetch = fetchWithTimeout as jest.Mock;
const event = {
id: '1',
title: 'Lørdagsquiz',
subtitle: 'Quiz med premier',
url: 'https://example.test/events/1',
futureOccurrencesCount: 1,
nextOccurrence: {
id: '11',
start: '2026-08-01T19:00:00+02:00',
venue: {
id: '2',
slug: 'storsalen',
title: 'Storsalen',
preposition: 'i',
url: 'https://example.test/venues/storsalen',
},
},
};
it('renders events from the API', async () => {
mockFetch.mockResolvedValue({ json: async () => ({ events: [event] }) });
const { findByText } = renderWithProviders(<EventList />);
expect(await findByText('Lørdagsquiz')).toBeTruthy();
});
it('renders an error state when the API is unreachable', async () => {
mockFetch.mockRejectedValue(new Error('network down'));
const { findByText } = renderWithProviders(<EventList />);
expect(await findByText('Kunne ikke hente programmet...')).toBeTruthy();
});
+1 -1
View File
@@ -26,7 +26,7 @@ const EventList = () => {
onPress={() => {
return Linking.openURL(item.url);
}}
style={[styles.listItem, ...(index === (events.length -1) ? [styles.lastListItem] : [])]}
style={[styles.listItem, ...(index === events.length - 1 ? [styles.lastListItem] : [])]}
>
<View style={styles.row}>
<View style={{ flex: 5 }}>
+3 -15
View File
@@ -1,4 +1,3 @@
import { useNavigation } from '@react-navigation/native';
import moment from 'moment';
import { useEffect, useState } from 'react';
@@ -8,18 +7,8 @@ import { fetchWithTimeout } from 'src/utils';
moment.locale('nb');
const formatDate = (time: string) => {
const m = moment(time);
return moment().year() === m.year() ? m.format('dddd D. MMMM') : m.format('ll');
};
type SectionedEvents = {
data: EventType[];
title: string;
};
/* Ref: https://medium.com/react-native-development/how-to-use-the-flatlist-component-react-native-basics-92c482816fe6 */
const useEventList = () => {
const { navigate } = useNavigation();
const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<EventType[]>([]);
const [error, setError] = useState<Error | unknown | null>(null);
@@ -35,17 +24,17 @@ const useEventList = () => {
setLoading(true);
let response;
let events;
let fetchedEvents;
try {
const res = await fetchWithTimeout(url);
response = await res.json();
events = response.events;
fetchedEvents = response.events;
} catch (err) {
setError(err);
setLoading(false);
return;
}
setEvents(events);
setEvents(fetchedEvents);
setError(response.error || null);
setLoading(false);
setRefreshing(false);
@@ -53,7 +42,6 @@ const useEventList = () => {
useEffect(() => {
fetchEvents();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import Login from 'src/modules/login/Login';
import { renderWithProviders } from 'src/testUtils';
it('renders the login form', () => {
const { getByText } = renderWithProviders(<Login />);
expect(getByText('E-post / Brukernavn')).toBeTruthy();
expect(getByText('Passord')).toBeTruthy();
expect(getByText('Logg inn')).toBeTruthy();
expect(getByText('Glemt passord?')).toBeTruthy();
});
@@ -0,0 +1,53 @@
import { gql } from '@apollo/client';
import React from 'react';
import Membership from 'src/modules/membership/Membership';
import { renderWithProviders, testUser } from 'src/testUtils';
// Must match PROOF_QUERY in useProof.tsx
const PROOF_QUERY = gql`
{
membershipTypes {
slug
price
}
}
`;
const apolloMocks = [
{
request: { query: PROOF_QUERY },
result: {
data: { membershipTypes: [{ __typename: 'MembershipType', slug: 'standard', price: 25000 }] },
},
},
];
it('shows the membership pitch when logged out', async () => {
const { findByText } = renderWithProviders(<Membership />);
expect(await findByText('Bli medlem')).toBeTruthy();
expect(await findByText('Logg inn')).toBeTruthy();
});
it('shows the membership proof when logged in', async () => {
// Mounting Membership with a token triggers requestUserData; serve the
// logged in user from the fake API so the refetch does not clobber state.
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 200,
json: async () => testUser,
});
const { findByText } = renderWithProviders(<Membership />, {
preloadedState: {
isAuthenticated: true,
userToken: 'test-token',
user: testUser,
},
apolloMocks,
});
expect(await findByText('MEDLEM')).toBeTruthy();
expect(await findByText('Test Testesen')).toBeTruthy();
});
@@ -0,0 +1,30 @@
import React from 'react';
import UserDelete from 'src/modules/membership/UserDelete';
import { renderWithProviders, testUser } from 'src/testUtils';
const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({ navigate: mockNavigate, goBack: jest.fn() }),
}));
beforeEach(() => {
mockNavigate.mockClear();
});
it('renders the delete confirmation for a logged in user', () => {
const { getByText } = renderWithProviders(<UserDelete />, {
preloadedState: { isAuthenticated: true, userToken: 'test-token', user: testUser },
});
expect(getByText('Slett meg')).toBeTruthy();
expect(mockNavigate).not.toHaveBeenCalled();
});
it('renders nothing and navigates back to Membership when not logged in', () => {
const { queryByText } = renderWithProviders(<UserDelete />);
expect(queryByText('Slett meg')).toBeNull();
expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' });
});
+10 -12
View File
@@ -71,27 +71,25 @@ const useProof = ({ fetchUser }: UseProofProps) => {
}
};
/* Refetech user data if memberships has expired */
/* Refetch user data if membership has expired */
const membershipType = user?.last_membership?.membership_type;
const membershipEndDate = user?.last_membership?.end_date;
useEffect(() => {
const todaysDate = new Date().toISOString().substring(0, 10);
if (
user?.lastMembership &&
user.lastMembership.membership_type !== 'lifelong' &&
user.lastMembership.end_date < todaysDate
) {
if (membershipEndDate && membershipType !== 'lifelong' && membershipEndDate < todaysDate) {
fetchUser();
// If payment was successful and notifications are enabled, schedule the notification
if (user?.last_membership?.end_date && hasPermission) {
scheduleMembershipExpiryNotification(user.last_membership.end_date);
if (hasPermission) {
scheduleMembershipExpiryNotification(membershipEndDate);
}
}
}, [user?.last_membership]);
}, [membershipType, membershipEndDate, fetchUser, hasPermission, scheduleMembershipExpiryNotification]);
useEffect(() => {
// eslint-disable-next-line no-restricted-syntax
for (const membershipType of data?.membershipTypes ?? []) {
if (membershipType.slug === membershipTypeSlug) {
setMembershipPrice(membershipType.price);
for (const membershipTypeOption of data?.membershipTypes ?? []) {
if (membershipTypeOption.slug === membershipTypeSlug) {
setMembershipPrice(membershipTypeOption.price);
}
}
}, [membershipTypeSlug, data?.membershipTypes]);
@@ -0,0 +1,12 @@
import React from 'react';
import UserRegister from 'src/modules/register/UserRegister';
import { renderWithProviders } from 'src/testUtils';
it('renders the registration form', () => {
const { getByText } = renderWithProviders(<UserRegister />);
expect(getByText('Fornavn')).toBeTruthy();
expect(getByText('E-post')).toBeTruthy();
expect(getByText('Registrer meg')).toBeTruthy();
});
+58
View File
@@ -0,0 +1,58 @@
import { InMemoryCache } from '@apollo/client';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import { NavigationContainer } from '@react-navigation/native';
import { render } from '@testing-library/react-native';
import React from 'react';
import type { ReactElement } from 'react';
import { Provider } from 'react-redux';
import { applyMiddleware, legacy_createStore as createStore } from 'redux';
import { thunk } from 'redux-thunk';
import rootReducer from 'src/reducers';
import type { UserType } from 'src/types/data';
type AppState = ReturnType<typeof rootReducer>;
const initialState: AppState = rootReducer(undefined, { type: '@@TEST_INIT' });
/** A member with an active membership, for screens that require a logged in user. */
export const testUser: UserType = {
username: 'testbruker',
email: 'test@example.test',
first_name: 'Test',
last_name: 'Testesen',
is_member: true,
is_volunteer: false,
last_membership: {
membership_type: 'standard',
end_date: '2099-08-01',
},
};
interface RenderWithProvidersOptions {
preloadedState?: Partial<AppState>;
apolloMocks?: ReadonlyArray<MockedResponse>;
}
/**
* Renders a screen inside the providers it expects in the real app:
* a fresh (non-persisted) Redux store, Apollo's MockedProvider and a
* NavigationContainer.
*/
export function renderWithProviders(
ui: ReactElement,
{ preloadedState, apolloMocks = [] }: RenderWithProvidersOptions = {},
) {
const store = createStore(rootReducer, { ...initialState, ...preloadedState }, applyMiddleware(thunk));
const result = render(
<MockedProvider mocks={apolloMocks} cache={new InMemoryCache()}>
<Provider store={store}>
<NavigationContainer>{ui}</NavigationContainer>
</Provider>
</MockedProvider>,
);
return { store, ...result };
}
-1
View File
@@ -1,5 +1,4 @@
import { NavigatorScreenParams } from '@react-navigation/native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { EventType } from './data';
+16 -8
View File
@@ -5,12 +5,20 @@ export function snakeToCamelCase(val: string): string {
export function fetchWithTimeout(url: string, options?: RequestInit, timeout = 10000) {
/** Wraps fetch to allow it to time out */
// FIXME: show timeout message in UI
return Promise.race([
fetch(url, options),
new Promise<Response>((_, reject) => {
setTimeout(() => {
reject(new Error('Fetch request timed out'));
}, timeout);
}),
]);
return new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Fetch request timed out'));
}, timeout);
fetch(url, options).then(
(response) => {
clearTimeout(timer);
resolve(response);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}