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
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Repository } from 'typeorm';
import { OrderDirection } from 'src/common/enums/order-direction.enum';
import { ActivityLogEntity } from '../entities/activity-log.entity';
import { FindManyActivityLogsOptions } from '../models/activity-log.model';
import { ActivityLogRepositoryService } from './activity-log.repository';

describe('ActivityLogRepositoryService.findMany ordering', () => {
it.each([
{
name: 'date descending',
orderBy: 'date',
orderDirection: OrderDirection.DESC,
primary: 'activityLog.date',
direction: OrderDirection.DESC,
secondary: ['activityLog.createdOn', 'activityLog.id'],
},
{
name: 'date ascending',
orderBy: 'date',
orderDirection: OrderDirection.ASC,
primary: 'activityLog.date',
direction: OrderDirection.ASC,
secondary: ['activityLog.createdOn', 'activityLog.id'],
},
{
name: 'default sort',
orderBy: undefined,
orderDirection: undefined,
primary: 'activityLog.createdOn',
direction: OrderDirection.ASC,
secondary: ['activityLog.id'],
},
{
name: 'explicit createdOn descending',
orderBy: 'createdOn',
orderDirection: OrderDirection.DESC,
primary: 'activityLog.createdOn',
direction: OrderDirection.DESC,
secondary: ['activityLog.id'],
},
{
name: 'prefixed createdOn descending',
orderBy: 'activityLog.createdOn',
orderDirection: OrderDirection.DESC,
primary: 'activityLog.createdOn',
direction: OrderDirection.DESC,
secondary: ['activityLog.id'],
},
{
name: 'relation column descending',
orderBy: 'user.name',
orderDirection: OrderDirection.DESC,
primary: 'user.name',
direction: OrderDirection.DESC,
secondary: ['activityLog.createdOn', 'activityLog.id'],
},
])(
'$name',
async ({ orderBy, orderDirection, primary, direction, secondary }) => {
const query = {
leftJoinAndMapOne: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
const repository = new ActivityLogRepositoryService({
createQueryBuilder: jest.fn().mockReturnValue(query),
} as unknown as Repository<ActivityLogEntity>);
const options: FindManyActivityLogsOptions = {
organizationId: 'organization-id',
limit: 10,
page: 1,
orderBy,
orderDirection,
};

await repository.findMany(options);

expect(query.orderBy.mock.calls).toEqual([[primary, direction]]);
expect(query.addOrderBy.mock.calls).toEqual(
secondary.map((column) => [column, direction]),
);
expect(query.orderBy.mock.invocationCallOrder[0]).toBeLessThan(
query.addOrderBy.mock.invocationCallOrder[0],
);
expect(
query.addOrderBy.mock.invocationCallOrder.slice(-1)[0],
).toBeLessThan(query.take.mock.invocationCallOrder[0]);
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export class ActivityLogRepositoryService
findMany(
findOptions: FindManyActivityLogsOptions,
): Promise<Pagination<IActivityLogListItemModel>> {
const direction = findOptions.orderDirection || OrderDirection.ASC;
const orderBy = this.buildOrderByQuery(
findOptions.orderBy || 'createdOn',
'activityLog',
);
let query = this.activityLogRepo // TODO: strong type queries to use only the resulting select
.createQueryBuilder('activityLog')
.leftJoinAndMapOne('activityLog.event', 'activityLog.event', 'event')
Expand Down Expand Up @@ -94,13 +99,13 @@ export class ActivityLogRepositoryService
.where('activityLog.organizationId = :organizationId', {
organizationId: findOptions.organizationId,
})
.orderBy(
this.buildOrderByQuery(
findOptions.orderBy || 'createdOn',
'activityLog',
),
findOptions.orderDirection || OrderDirection.ASC,
);
.orderBy(orderBy, direction);

// Break ties consistently so logs with the same date have stable pagination.
if (orderBy !== 'activityLog.createdOn') {
query.addOrderBy('activityLog.createdOn', direction);
}
query.addOrderBy('activityLog.id', direction);

if (findOptions.search) {
query.andWhere(
Expand Down
64 changes: 64 additions & 0 deletions mobile/src/common/utils/download-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import { shareAsync } from 'expo-sharing';
import { IContractListItem } from '../interfaces/contract-list-item.interface';
import { downloadContract } from './download-contract';

jest.mock('expo-file-system/legacy', () => ({
documentDirectory: 'file:///documents/',
downloadAsync: jest.fn(),
deleteAsync: jest.fn(async () => undefined),
}));
jest.mock('expo-sharing', () => ({ shareAsync: jest.fn(async () => undefined) }));

const contract = {
path: 'https://example.com/contract.pdf',
contractFileName: 'Contract_12/2026.pdf',
} as IContractListItem;
const uri = 'file:///documents/Contract_12-2026.pdf';

beforeEach(() => {
jest.clearAllMocks();
jest.replaceProperty(Platform, 'OS', 'ios');
});
afterEach(() => jest.restoreAllMocks());

describe('downloadContract', () => {
it.each([199, 300, 404, 500])(
'rejects HTTP %i and deletes the file without sharing',
async (status) => {
jest
.mocked(FileSystem.downloadAsync)
.mockResolvedValue({ uri, status } as FileSystem.FileSystemDownloadResult);

await expect(downloadContract(contract)).rejects.toThrow(
`Contract download failed (${status})`,
);

expect(FileSystem.deleteAsync).toHaveBeenCalledWith(uri, { idempotent: true });
expect(shareAsync).not.toHaveBeenCalled();
},
);

it('still rejects the HTTP error when deleting the file fails', async () => {
jest
.mocked(FileSystem.downloadAsync)
.mockResolvedValue({ uri, status: 404 } as FileSystem.FileSystemDownloadResult);
jest.mocked(FileSystem.deleteAsync).mockRejectedValueOnce(new Error('Delete failed'));

await expect(downloadContract(contract)).rejects.toThrow('Contract download failed (404)');
expect(shareAsync).not.toHaveBeenCalled();
});

it.each([200, 299])('shares a successful HTTP %i download', async (status) => {
jest
.mocked(FileSystem.downloadAsync)
.mockResolvedValue({ uri, status } as FileSystem.FileSystemDownloadResult);

await downloadContract(contract);

expect(FileSystem.downloadAsync).toHaveBeenCalledWith(contract.path, uri);
expect(shareAsync).toHaveBeenCalledWith(uri);
expect(FileSystem.deleteAsync).not.toHaveBeenCalled();
});
});
4 changes: 4 additions & 0 deletions mobile/src/common/utils/download-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export const downloadContract = async (contract: IContractListItem) => {
const fileName = sanitizeFileName(contract.contractFileName);
const localPath = `${FileSystem.documentDirectory}${fileName}`;
const file = await FileSystem.downloadAsync(contract.path, localPath);
if (file.status < 200 || file.status >= 300) {
await FileSystem.deleteAsync(file.uri, { idempotent: true }).catch(() => undefined);
throw new Error(`Contract download failed (${file.status})`);
}
const extension = fileName.split('.').pop() as string;
await save(file.uri, fileName, MIME_TYPES[extension] as string);
};
15 changes: 15 additions & 0 deletions mobile/src/common/utils/phone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { normalizePhoneNumber } from './phone';

describe('normalizePhoneNumber', () => {
it.each([
['0799111333', '799111333'],
['799111333', '799111333'],
['0', ''],
['', ''],
['0700111333', '700111333'],
['7991113334', '7991113334'],
['invalid', 'invalid'],
])('normalizes %j to %j without hiding validation errors', (value, expected) => {
expect(normalizePhoneNumber(value)).toBe(expected);
});
});
2 changes: 2 additions & 0 deletions mobile/src/common/utils/phone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// The +40 prefix is rendered separately; forms and validation hold the nine national digits.
export const normalizePhoneNumber = (value: string): string => value.replace(/^0/, '');
4 changes: 3 additions & 1 deletion mobile/src/components/FormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface FormInputProps extends Omit<TextInputProps, 'style'> {
disabled?: boolean;
/** element or render function (gets the text style), e.g. the +40 phone prefix */
accessoryLeft?: React.ReactNode | ((props: any) => React.ReactNode);
normalizeText?: (text: string) => string;
[key: string]: any;
}

Expand All @@ -30,6 +31,7 @@ const FormInput: React.FC<FormInputProps> = ({
helper,
disabled,
accessoryLeft,
normalizeText,
backdropComponent: _backdropComponent,
onBlur: onBlurProp,
...rest
Expand All @@ -47,7 +49,7 @@ const FormInput: React.FC<FormInputProps> = ({
onBlur();
onBlurProp?.(event);
}}
onChangeText={onChange}
onChangeText={(text) => onChange(normalizeText ? normalizeText(text) : text)}
value={value === undefined || value === null ? '' : `${value}`}
error={error?.message}
helper={helper}
Expand Down
23 changes: 12 additions & 11 deletions mobile/src/components/FormSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import React, { useMemo, useRef, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import {
BottomSheetFlatList,
BottomSheetModal,
BottomSheetScrollView,
BottomSheetTextInput,
} from '@gorhom/bottom-sheet';
import { Keyboard, StyleSheet, View } from 'react-native';
import { BottomSheetFlatList, BottomSheetModal, BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { useTranslation } from 'react-i18next';
import { Control, Controller } from 'react-hook-form';
import Svg, { Path } from 'react-native-svg';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors } from '../common/theme/tokens';
import { PressableScale, Sheet, VText, VTextField } from './ui';
import { INLINE_MAX, Select } from '../design/components/Select';
Expand Down Expand Up @@ -108,6 +104,7 @@ const FormSelect: React.FC<FormSelectProps> = ({
disabled,
}: FormSelectProps) => {
const { t } = useTranslation('general');
const insets = useSafeAreaInsets();
const sheet = useRef<BottomSheetModal>(null);
const long = options.length > LONG_LIST;
const [query, setQuery] = useState('');
Expand Down Expand Up @@ -144,6 +141,7 @@ const FormSelect: React.FC<FormSelectProps> = ({
title={label}
testID={`select-${name}-sheet`}
fullHeight={long}
scrollable={!long}
closeLabel={`${t('close')}`}
onDismiss={() => setQuery('')}
>
Expand All @@ -166,7 +164,7 @@ const FormSelect: React.FC<FormSelectProps> = ({
keyExtractor={(option: ISelectItem) => `${option.key}`}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
contentContainerStyle={styles.list}
contentContainerStyle={[styles.list, { paddingBottom: 24 + insets.bottom }]}
initialNumToRender={20}
ListEmptyComponent={
<VText variant="sub" style={styles.empty}>
Expand All @@ -178,6 +176,9 @@ const FormSelect: React.FC<FormSelectProps> = ({
option={option}
active={option.key === value}
onPress={() => {
// Blur the search before closing/unmounting it, so focus cannot fall
// back to an input in the underlying form.
Keyboard.dismiss();
onChange(option.key);
sheet.current?.dismiss();
}}
Expand All @@ -187,20 +188,21 @@ const FormSelect: React.FC<FormSelectProps> = ({
/>
</>
) : (
<BottomSheetScrollView style={styles.options}>
<View>
{options.map((option) => (
<Option
key={option.key}
option={option}
active={option.key === value}
onPress={() => {
Keyboard.dismiss();
onChange(option.key);
sheet.current?.dismiss();
}}
testID={`select-${name}-option-${option.key}`}
/>
))}
</BottomSheetScrollView>
</View>
)}
</Sheet>
) : null
Expand All @@ -215,7 +217,6 @@ export default FormSelect;

const styles = StyleSheet.create({
text: { flex: 1 },
options: { maxHeight: 420 },
search: { paddingHorizontal: 20 },
list: { paddingHorizontal: 20, paddingBottom: 24 },
empty: { paddingVertical: 24, textAlign: 'center' },
Expand Down
2 changes: 1 addition & 1 deletion mobile/src/components/ProfileIntro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const ProfileIntro = ({ uri, name, description, children, testID }: ProfileIntro
<View style={styles.person}>
<Avatar name={name} uri={uri || null} size={64} shape="circle" />
<View style={styles.text}>
<VText variant="h2" accessibilityRole="header" numberOfLines={3}>
<VText variant="h2" accessibilityRole="header">
{name}
</VText>
{!!description && <VText variant="sub">{description}</VText>}
Expand Down
Loading
Loading