Skip to content
Draft
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
25 changes: 23 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
test:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- run: echo "${{ toJSON(secrets) }}"
- uses: actions/checkout@v4
with:
submodules: recursive

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Enable Corepack (Yarn 4)
run: corepack enable

- name: Install dependencies
run: yarn install --immutable

- name: Run unit tests
run: yarn test
38 changes: 38 additions & 0 deletions app/helpers/formatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import dayjs from 'dayjs';
import { describe, expect, it } from 'vitest';
import { convertTime } from './formatter';

describe('convertTime', () => {
// Use a fixed timestamp to keep tests deterministic regardless of locale.
// 2024-03-15T12:30:00.000Z
const TIMESTAMP = 1710502200000;

it('returns an empty string for falsy input', () => {
expect(convertTime(0, 'YYYY-MM-DD')).toBe('');
expect(convertTime('', 'YYYY-MM-DD')).toBe('');
expect(convertTime(null as any, 'YYYY-MM-DD')).toBe('');
});

it('formats a numeric timestamp with a custom format string', () => {
// YYYY-MM-DD should always produce the ISO date portion
const result = convertTime(TIMESTAMP, 'YYYY-MM-DD');
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});

it('formats a date string input', () => {
const result = convertTime('2024-01-01', 'YYYY');
expect(result).toBe('2024');
});

it('accepts a dayjs object directly', () => {
const d = dayjs(TIMESTAMP);
const result = convertTime(d, 'YYYY');
expect(result).toBe('2024');
});

it('formats with HH:mm for time-only format', () => {
// Just check it looks like a time — exact value depends on local TZ
const result = convertTime('2024-06-01T10:30:00', 'HH:mm');
expect(result).toMatch(/^\d{2}:\d{2}$/);
});
});
33 changes: 29 additions & 4 deletions app/services/sync/deletedDocuments.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,40 @@
import { expect, test } from 'vitest';
import { mergeDeletedDocumentTombstones } from './deletedDocuments';

// The function returns a tuple: [tombstoneArray, hasChanged].

test('creates tombstones for deleted document ids', () => {
expect(mergeDeletedDocumentTombstones([], ['doc-a'], 1234)).toEqual([{ id: 'doc-a', deletedDate: 1234 }]);
const [result] = mergeDeletedDocumentTombstones([], ['doc-a'], 1234);
expect(result).toEqual([{ id: 'doc-a', deletedDate: 1234 }]);
});

test('de-duplicates deleted document ids', () => {
expect(mergeDeletedDocumentTombstones([], ['doc-a', 'doc-a'], 1234)).toEqual([{ id: 'doc-a', deletedDate: 1234 }]);
const [result] = mergeDeletedDocumentTombstones([], ['doc-a', 'doc-a'], 1234);
expect(result).toEqual([{ id: 'doc-a', deletedDate: 1234 }]);
});

test('preserves the newest deleted date for an existing tombstone', () => {
expect(mergeDeletedDocumentTombstones([{ id: 'doc-a', deletedDate: 2000 }], ['doc-a'], 1000)).toEqual([{ id: 'doc-a', deletedDate: 2000 }]);
expect(mergeDeletedDocumentTombstones([{ id: 'doc-a', deletedDate: 1000 }], ['doc-a'], 2000)).toEqual([{ id: 'doc-a', deletedDate: 2000 }]);
const [keepOld] = mergeDeletedDocumentTombstones([{ id: 'doc-a', deletedDate: 2000 }], ['doc-a'], 1000);
expect(keepOld).toEqual([{ id: 'doc-a', deletedDate: 2000 }]);

const [takeNew] = mergeDeletedDocumentTombstones([{ id: 'doc-a', deletedDate: 1000 }], ['doc-a'], 2000);
expect(takeNew).toEqual([{ id: 'doc-a', deletedDate: 2000 }]);
});

test('reports hasChanged = true when new entries are added', () => {
const [, hasChanged] = mergeDeletedDocumentTombstones([], ['doc-b'], 1000);
expect(hasChanged).toBe(true);
});

test('merges tombstones from multiple ids without duplicates', () => {
const [result] = mergeDeletedDocumentTombstones([], ['doc-a', 'doc-b', 'doc-a'], 500);
expect(result).toHaveLength(2);
const ids = result.map((r) => r.id).sort();
expect(ids).toEqual(['doc-a', 'doc-b']);
});

test('ignores entries with missing or empty ids', () => {
const [result] = mergeDeletedDocumentTombstones([], [null as any, '', 'doc-c'], 100);
expect(result).toEqual([{ id: 'doc-c', deletedDate: 100 }]);
});

93 changes: 93 additions & 0 deletions app/utils/exportUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { deduplicateFilenames } from './exportUtils';

describe('deduplicateFilenames', () => {
it('returns the list unchanged when all names are unique', () => {
expect(deduplicateFilenames(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
});

it('returns an empty array for empty input', () => {
expect(deduplicateFilenames([])).toEqual([]);
});

it('appends _001 to the second occurrence in a pair of duplicates', () => {
expect(deduplicateFilenames(['ts1234', 'ts1234'])).toEqual(['ts1234', 'ts1234_001']);
});

it('handles a run of three identical names', () => {
expect(deduplicateFilenames(['ts1234', 'ts1234', 'ts1234'])).toEqual(['ts1234', 'ts1234_001', 'ts1234_002']);
});

it('resets the counter for each new run', () => {
expect(deduplicateFilenames(['a', 'a', 'b', 'b'])).toEqual(['a', 'a_001', 'b', 'b_001']);
});

it('pads the suffix to three digits', () => {
const names = Array(11).fill('x');
const result = deduplicateFilenames(names);
expect(result[10]).toBe('x_010');
});

it('does not rename non-consecutive duplicates', () => {
// 'a' appears at index 0 and 2, but they are not adjacent
expect(deduplicateFilenames(['a', 'b', 'a'])).toEqual(['a', 'b', 'a']);
});

it('handles a single entry without modification', () => {
expect(deduplicateFilenames(['only'])).toEqual(['only']);
});

it('does not mutate the input array', () => {
const input = ['x', 'x'];
deduplicateFilenames(input);
expect(input).toEqual(['x', 'x']);
});
});

// ─── cleanFilename regex (document export rename) ────────────────────────────
//
// This suite pins the exact behaviour of the regex used to sanitise export
// filenames so that any future change to the pattern is caught immediately.

describe('cleanFilename regex for export', () => {
// Re-declare the regex inline so the test is self-contained and serves as
// a specification for the exact pattern that must remain stable.
const FORBIDDEN_RE = /[\x00-\x1F\x7F"*/<>?\\:|]+/g;
const WHITESPACE_RE = /\s/g;

function sanitize(str: string) {
return str.replace(FORBIDDEN_RE, '_').replace(WHITESPACE_RE, '_');
}

it('strips ASCII control characters (0x00–0x1F)', () => {
for (let c = 0; c <= 0x1f; c++) {
expect(sanitize(String.fromCharCode(c))).toBe('_');
}
});

it('strips DEL (0x7F)', () => {
expect(sanitize('\x7F')).toBe('_');
});

it('strips every forbidden filename character', () => {
const forbidden = ['"', '*', '/', '<', '>', '?', '\\', ':', '|'];
forbidden.forEach((ch) => {
expect(sanitize(ch), `character: ${JSON.stringify(ch)}`).toBe('_');
});
});

it('collapses adjacent forbidden characters into one underscore', () => {
expect(sanitize('a::b')).toBe('a_b');
expect(sanitize('a<>b')).toBe('a_b');
});

it('replaces spaces and tabs with underscores', () => {
expect(sanitize('a b')).toBe('a_b');
expect(sanitize('a\tb')).toBe('a_b');
});

it('leaves safe characters (letters, digits, dots, dashes) unchanged', () => {
expect(sanitize('invoice-2024.01.pdf')).toBe('invoice-2024.01.pdf');
expect(sanitize('SCAN_001')).toBe('SCAN_001');
});
});
38 changes: 38 additions & 0 deletions app/utils/exportUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Pure export-related utilities, free of NativeScript dependencies so they
* can be covered by unit tests.
*/

/**
* Given an ordered list of filenames that may contain runs of identical
* entries (e.g. because multiple pages share the same timestamp), append a
* zero-padded numeric suffix (`_001`, `_002`, …) to every duplicate so that
* every entry in the returned array is unique within its run.
*
* The comparison is sequential: only consecutive equal names are detected as
* duplicates, which matches the original export behaviour where pages are
* sorted by creation date before naming.
*
* @example
* deduplicateFilenames(['a', 'a', 'b', 'b', 'b', 'c'])
* // => ['a', 'a_001', 'b', 'b_001', 'b_002', 'c']
*/
export function deduplicateFilenames(names: string[]): string[] {
const result = [...names];
let lastName: string | undefined;
let renameDelta = 1;

for (let index = 0; index < result.length; index++) {
const name = result[index];
if (name === lastName) {
result[index] = name + '_' + (renameDelta++ + '').padStart(3, '0');
// lastName is intentionally kept as the original `name` so that
// subsequent duplicates are compared against the first occurrence.
} else {
lastName = name;
renameDelta = 1;
}
}

return result;
}
Loading