Files
native/src/device-sync/__tests__/engine/task.test.ts
T
Linus Rath 029eba4562 fix: keep the last sync's result when a sync after it finds nothing to do #34
The settings show the stored status of the last sync. A sync that
finds nothing to do still replaced it, so a change the server refused
and the phone put back, or a conflict, was shown for a second or two
and then gone. The previous fix covered only the upload sync Android
starts right after a manual one. When changes arrive during a sync,
the adapter runs a full sync right after it, which finds nothing new
on the server and replaced the status 1-2 s after the real one; a
manual sync right after another does the same.

Now what counts is what a run did, not how it started. A run that
wrote nothing either way and reported no conflict, item error, note or
failure keeps the details of the run before it and moves only the time
on. After a failed run it shows the sync as working again, without the
failure's message. Any other run replaces the status, so a problem
stays only until the next run that does something.
2026-09-27 10:20:54 +02:00

76 lines
3.0 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 { emptyStats } from '../../engine/report';
import { createTaskDeps, 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('reads back the status it records, for a run that did nothing to keep', async () => {
const deps = createTaskDeps();
const status = { at: 5, outcome: 'ok' as const, durationMs: 3, conflicts: 1, itemErrors: 2, stats: emptyStats() };
await deps.recordStatus('bob@mail.example.com', 'com.android.calendar', status);
await expect(deps.lastStatus('bob@mail.example.com', 'com.android.calendar')).resolves.toEqual(status);
await expect(deps.lastStatus('bob@mail.example.com', 'com.android.contacts')).resolves.toBeUndefined();
await expect(deps.lastStatus('nobody@mail.example.com', 'com.android.calendar')).resolves.toBeUndefined();
});
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);
});
});