Files
native/src/device-sync/__tests__/engine/task.test.ts
T
Linus Rath 96a6b38321 feat: sync the device's contacts and calendars with the server in the background #34
The sync adapters from ed2525e start a headless task for every sync, but
the task only reported "disabled". This adds the engine it runs
(`src/device-sync/engine`, `src/device-sync/jmap`): for one Android
account and authority it downloads the server's changes, uploads what
the device changed, and hands a report back to SyncManager.

- Downloads follow `/changes` from the state kept in the provider's
  SyncState. Rows and the state they reach are written in one batch, so
  a killed run resumes where it stopped. Without a state, after
  `cannotCalculateChanges` or after the user discarded deletions, a full
  reconcile compares ids instead.
- Uploads go in three phases across accounts (creates, updates,
  deletions), so moving an item to another account never destroys it
  before the copy exists. A create carries a uid written to the device
  first, and the server is asked for that uid before creating, so a lost
  response or a killed run never makes a duplicate. Updates are patches
  guarded by `ifInState`; nothing is replaced whole.
- More than 50 deletions that are also more than a fifth of the synced
  items hold every deletion back and report `tooManyDeletions`, so
  Android asks the user; "undo" restores the items from the server.
- An item the server refuses is marked and not sent again until it
  changes or its back-off (1 hour, doubling to a day) ends. Such
  refusals and planner errors stay with their item; the rest of the run
  goes on.
- Runs of one account and authority take a lock, stop at checkpoints
  when cancelled or close to the deadline, and never throw: every
  failure becomes an outcome, and a rejected sign-in posts one "Sign in
  again" notification.
- The JMAP side uses a client of its own per app account, never the
  UI's, since a run can share the JS runtime with the app.

Turning sync off or signing out runs the same upload first
(`teardownAuthority`) and deletes the rows only once nothing is waiting,
or when the user agreed to lose it.

The planners that map items to rows land in the next commits; until
then a run fails before it touches a row.

Checked: 94 engine tests against the fake provider and JMAP server
(sync flows, failures, crashes and retries, teardown), and `npm run
typecheck`, `npm test` and `npm run i18n:check` on this commit.
2026-09-27 04:19:21 +02:00

64 lines
2.3 KiB
TypeScript

// The headless task entry: every run ends with finishRun and nothing
// escapes it (docs/device-sync.md, "The Android shell").
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../native', () => ({
createProviderPort: vi.fn(),
finishRun: vi.fn(async () => true),
getSyncSettings: vi.fn(async () => ({
masterAutomatic: true,
authorities: { 'com.android.contacts': { automatic: false, syncable: 1, periodicSeconds: 0, active: false, pending: false } },
})),
isRunCancelled: vi.fn(async () => false),
showSyncProblem: vi.fn(async () => undefined),
}));
import * as native from '../../native';
import { runDeviceSyncTask, teardownAuthority } from '../../task';
import { useDeviceSyncStore } from '../../../stores/device-sync-store';
import type { RunPayload } from '../../types';
const payload: RunPayload = {
runId: 'r1',
accountName: 'alice@example.com',
registryId: 'alice@mail.example.com',
authority: 'com.android.contacts',
extras: {},
deadline: Date.now() + 60_000,
};
describe('device sync task', () => {
beforeEach(() => {
vi.mocked(native.finishRun).mockClear();
});
it('hands the report to the adapter before settling, and records the status', async () => {
await runDeviceSyncTask(payload);
expect(native.finishRun).toHaveBeenCalledWith('r1', expect.objectContaining({ v: 1, runId: 'r1', outcome: 'disabled' }));
expect(useDeviceSyncStore.getState().accounts['alice@mail.example.com']?.lastRun?.['com.android.contacts']).toMatchObject({
outcome: 'disabled',
});
});
it('reports a failure instead of throwing, and survives a refused finishRun', async () => {
vi.mocked(native.getSyncSettings).mockRejectedValueOnce(new Error('module gone'));
vi.mocked(native.finishRun).mockRejectedValueOnce(new Error('bridge down'));
await expect(runDeviceSyncTask({ ...payload, runId: 'r2' })).resolves.toBeUndefined();
expect(native.finishRun).toHaveBeenCalledWith('r2', expect.objectContaining({ outcome: 'internal', message: 'module gone' }));
});
it('ignores a payload without a run', async () => {
await runDeviceSyncTask({} as RunPayload);
expect(native.finishRun).not.toHaveBeenCalled();
});
it('exports the teardown the app calls', () => {
expect(teardownAuthority).toBeTypeOf('function');
expect(teardownAuthority.length).toBe(4);
});
});