Extract the operational Model Delivery PWA

This commit is contained in:
tomchukk committed 2026-09-07 19:34:48 +03:00
commit 958f8d5ff1
46 files changed
+6446

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
import { test, expect } from '@playwright/test';
import { testAccount } from '../helpers/account';
test('local recovery settings, masked secret, SSO greeting and offline re-entry', async ({ page, context }) => {
const request = context.request;
const state = await (await request.get('/api/local-admin/status')).json();
if (state.setupRequired) {
expect((await request.post('/api/local-admin/setup', { headers: { 'x-model-request': '1' }, data: { ...testAccount, setupToken: 'isolated-test-bootstrap' } })).ok()).toBe(true);
await request.post('/api/auth/logout', { headers: { 'x-model-request': '1' } });
}
await page.goto('/');
await expect(page.getByRole('button', { name: 'כניסה עם חשבון המעבדה' })).toBeVisible();
await expect(page.locator('a[href="/local-admin"]')).toHaveCount(0);
await page.goto('/local-admin');
await page.getByLabel('שם משתמש', { exact: true }).fill(testAccount.username);
await page.getByLabel('סיסמה', { exact: true }).fill(testAccount.password);
await page.getByRole('button', { name: 'כניסה מקומית', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Synology SSO Server' })).toBeVisible();
await page.getByLabel('כתובת המנפיק (Issuer)', { exact: true }).fill('http://localhost:4005');
await page.getByLabel('כתובת גילוי (Well-known URL)').fill('http://localhost:4005/.well-known/openid-configuration');
await page.getByLabel('מזהה לקוח (Application ID)', { exact: true }).fill('model-test');
await page.getByLabel('סוד לקוח (Application Secret)').fill('mock-test-secret');
await page.getByLabel('כתובת חזרה (Redirect URI)').fill('http://localhost:4174/api/auth/callback');
await page.getByLabel('הרשאות (Scopes)').fill('openid profile email groups');
await page.getByLabel('הגבלת הכניסה לפי קבוצות Synology / AD').check();
await page.getByLabel('שם שדה הקבוצות').fill('groups');
await page.getByLabel('קבוצות מורשות — קבוצה בכל שורה').fill('model-delivery-users');
await page.getByLabel('הפעלת כניסה ארגונית').check();
await page.getByLabel('בקשת offline_access לחידוש הכניסה').check();
await page.getByRole('button', { name: 'שמירת הגדרות', exact: true }).click();
await expect(page.getByRole('status')).toContainText('ההגדרות נשמרו');
await expect(page.getByLabel('סוד לקוח (Application Secret)')).toHaveValue('');
await page.getByRole('button', { name: 'בדיקת ההגדרות השמורות' }).click();
await expect(page.getByRole('status')).toContainText('החיבור לשרת אומת');
const directory = page.locator('.directory-settings');
await expect(directory.getByRole('heading', { name: 'שמות מתוך Active Directory' })).toBeVisible();
await directory.getByLabel('קריאת שמות מהספרייה', { exact: true }).uncheck();
await directory.getByLabel('כתובת הספרייה (LDAPS)').fill('ldaps://directory.example.invalid:636');
await directory.getByLabel('חשבון קריאה (Bind DN / UPN)').fill('reader@example.invalid');
await directory.getByLabel('סיסמת חשבון הקריאה').fill('browser-test-directory-secret');
await directory.getByRole('button', { name: 'שמירת הגדרות הספרייה' }).click();
await expect(directory.getByRole('status')).toContainText('הגדרות הספרייה נשמרו');
await expect(directory.getByLabel('סיסמת חשבון הקריאה')).toHaveValue('');
await directory.getByLabel('מזהה SSO לבדיקה').fill('REZNIK\\test.sso');
await directory.getByRole('button', { name: 'בדיקת שם מהספרייה' }).click();
await expect(directory.getByRole('alert')).toContainText('קריאת השמות כבויה');
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.screenshot({ path: 'test-results/model-delivery-local-admin.png', fullPage: true });
await page.getByRole('link', { name: 'פתיחת האפליקציה' }).click();
await expect(page.locator('.greeting')).toHaveText(`שלום, ${testAccount.displayName}`);
await page.getByRole('button', { name: 'החשבון שלי' }).click();
await page.getByRole('button', { name: 'יציאה מהחשבון' }).click();
await page.getByRole('button', { name: 'כניסה עם חשבון המעבדה' }).click();
await expect(page.locator('.greeting')).toHaveText('שלום, טום בדיקה');
await page.getByRole('button', { name: 'החשבון שלי' }).click();
await page.getByLabel('שם לתצוגה', { exact: true }).fill('תום רוף בדיקה');
await page.getByRole('button', { name: 'שמירת השם', exact: true }).click();
await expect(page.locator('.greeting')).toHaveText('שלום, תום רוף בדיקה');
const session = await (await request.get('/api/auth/session')).json();
expect(session.user.authProvider).toBe('oidc');
expect(session.user.groups).toEqual(['model-delivery-users']);
expect(Object.keys(session).sort()).toEqual(['expiresAt', 'user']);
expect(await page.evaluate(() => Object.keys(localStorage).some(key => /token|secret/i.test(key)))).toBe(false);
await page.evaluate(async () => { await navigator.serviceWorker.ready; });
await page.reload();
await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true);
await context.setOffline(true);
await page.reload();
await expect(page.locator('.greeting')).toHaveText('שלום, תום רוף בדיקה');
await context.setOffline(false);
await page.getByRole('button', { name: 'החשבון שלי' }).click();
await page.getByRole('button', { name: 'שימוש בשם הארגוני' }).click();
await expect(page.locator('.greeting')).toHaveText('שלום, טום בדיקה');
// Restore this reusable test user's preference after checking offline persistence.
expect((await request.post('/api/auth/profile', { headers: { 'x-model-request': '1', 'x-model-user': session.user.id }, data: { displayName: 'טום בדיקה' } })).ok()).toBe(true);
await page.goto('/local-admin');
await expect(page.getByRole('button', { name: 'כניסה מקומית', exact: true })).toBeVisible();
await expect(page.getByLabel('סוד לקוח (Application Secret)')).toHaveCount(0);
await expect(page.locator('.directory-settings')).toHaveCount(0);
// Only the isolated test database is configured by this test. Disable its test provider afterwards.
const local = await request.post('/api/local-admin/login', { headers: { 'x-model-request': '1' }, data: { username: testAccount.username, password: testAccount.password } });
expect(local.ok()).toBe(true);
const { secretConfigured: _, ...settings } = await (await request.get('/api/local-admin/settings')).json();
expect((await request.post('/api/local-admin/settings', { headers: { 'x-model-request': '1' }, data: { ...settings, enabled: false } })).ok()).toBe(true);
});
+266
View File
@@ -0,0 +1,266 @@
import { test, expect, type Page } from '@playwright/test';
import { testAccount } from '../helpers/account';
import { randomUUID } from 'node:crypto';
test.beforeEach(async ({ context }) => {
const request = context.request;
const state = await (await request.get('/api/local-admin/status')).json();
const result = await request.post(`/api/local-admin/${state.setupRequired ? 'setup' : 'login'}`, { headers: { 'x-model-request': '1' }, data: state.setupRequired ? { ...testAccount, setupToken: 'isolated-test-bootstrap' } : { username: testAccount.username, password: testAccount.password } });
expect(result.ok()).toBe(true);
});
async function deliveryForm(page: Page, notes: string) {
await page.getByRole('combobox', { name: 'לקוח', exact: true }).selectOption({ label: 'מרפאת הדגמה' });
await page.getByRole('combobox', { name: 'כתובת למשלוח', exact: true }).selectOption('20000000-0000-4000-8000-000000000002');
await page.getByRole('combobox', { name: 'סוג משלוח', exact: true }).selectOption('10000000-0000-4000-8000-000000000003');
await page.getByLabel('מחיר ליחידה (₪)').fill('10.01');
await page.getByRole('textbox', { name: /הערות למשלוח/ }).fill(notes);
}
async function openDeliveryView(page: Page) {
const heading = page.getByRole('heading', { name: 'המשלוחים שלך' });
if (!await heading.isVisible()) await page.getByRole('button', { name: 'משלוחים', exact: true }).click();
await expect(heading).toBeVisible();
}
test('online creation then offline save, offline reload, reconnect and server verification', async ({ page, context }) => {
const request = context.request;
const browserErrors: string[] = [];
page.on('pageerror', error => browserErrors.push(error.message));
await page.goto('/');
await expect(page.getByRole('heading', { name: 'תמונת המצב שלך' })).toBeVisible();
await expect(page.locator('.day-card')).toHaveCount(7);
await expect(page.getByRole('region', { name: 'פעולות מהירות' }).getByRole('button')).toHaveCount(4);
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect.poll(() => page.locator('.sidebar').evaluate(element => { const rect = element.getBoundingClientRect(); return rect.left >= innerWidth || rect.right <= 0; })).toBe(true);
await page.screenshot({ path: 'test-results/model-delivery-dashboard-mobile.png' });
await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole('button', { name: /משלוח חדש/ }).click();
await expect(page.getByRole('heading', { name: 'המשלוחים שלך' })).toBeVisible();
await expect(page.getByRole('combobox', { name: 'לקוח', exact: true }).locator('option')).toContainText(['בחירת לקוח', 'מרפאת הדגמה']);
await expect(page.getByLabel('תאריך מתוכנן')).toHaveValue(/^\d{4}-\d{2}-\d{2}$/);
await page.evaluate(async () => { await navigator.serviceWorker.ready; });
await page.reload();
await openDeliveryView(page);
await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true);
const onlineNotes = `Online browser test ${Date.now()}`;
await deliveryForm(page, onlineNotes);
await page.getByRole('radio', { name: /שעה אחרת/ }).check();
await page.getByLabel('שעה מדויקת', { exact: true }).fill('14:35');
await page.getByRole('button', { name: '+ שמירת משלוח', exact: true }).click();
await expect(page.locator('.detail-panel')).toContainText(onlineNotes);
await expect(page.locator('.detail-panel')).toContainText('שעה אחרת · 14:35');
await expect(page.locator('.delivery-row[aria-pressed="true"]')).toContainText('מסונכרן');
await expect(page.locator('.delivery-row[aria-pressed="true"]')).toContainText('#');
await context.setOffline(true);
await expect(page.locator('.connection')).toContainText('ללא חיבור לרשת');
const offlineNotes = `Offline browser test ${Date.now()}`;
await deliveryForm(page, offlineNotes);
await page.getByRole('radio', { name: /צהריים/ }).check();
await page.getByRole('button', { name: '+ שמירת משלוח', exact: true }).click();
await expect(page.locator('.detail-panel')).toContainText(offlineNotes);
const offlineRow = page.locator('.delivery-row[aria-pressed="true"]');
await expect(offlineRow).toContainText('ממתין לסנכרון');
await expect(offlineRow).toContainText('ממתין למספר');
const rowId = await offlineRow.getAttribute('data-testid');
expect(rowId).toBeTruthy();
await page.reload();
await openDeliveryView(page);
await expect(page.getByTestId(rowId!)).toContainText('ממתין לסנכרון');
await expect(page.getByTestId(rowId!)).toContainText('ממתין למספר');
await context.setOffline(false);
try { await expect(page.getByTestId(rowId!)).toContainText('מסונכרן'); }
catch (error) {
console.log(await page.evaluate(async () => ({ online: navigator.onLine, connection: document.querySelector('.connection')?.textContent, sync: document.querySelector('.sync-button')?.textContent, alerts: Array.from(document.querySelectorAll('.alert')).map(e => e.textContent), locks: await navigator.locks.query() })));
throw error;
}
await expect(page.getByTestId(rowId!)).toContainText('#');
await page.reload();
await openDeliveryView(page);
await expect(page.getByTestId(rowId!)).toContainText('מסונכרן');
const allChanges: Array<{ change: { entityType: string; record: { id: string; notes?: string; deliveryNumber?: number; deliverySlot?: string; specificTime?: string | null } } }> = [];
let cursor = '0';
const session = await (await request.get('/api/auth/session')).json();
while (true) {
const response = await request.get(`/api/sync/pull?cursor=${cursor}&limit=200`, { headers: { 'x-model-user': session.user.id } });
expect(response.ok()).toBe(true);
const result = await response.json(); allChanges.push(...result.changes); cursor = result.nextCursor;
if (!result.hasMore) break;
}
const saved = allChanges.filter(c => c.change.entityType === 'delivery' && c.change.record.notes === offlineNotes);
expect(saved).toHaveLength(1);
expect(`delivery-${saved[0]!.change.record.id}`).toBe(rowId);
expect(saved[0]!.change.record.deliveryNumber).toBeGreaterThan(0);
expect(saved[0]!.change.record.deliverySlot).toBe('afternoon');
const onlineSaved = allChanges.filter(c => c.change.record.notes === onlineNotes);
expect(onlineSaved).toHaveLength(1);
expect(onlineSaved[0]!.change.record).toMatchObject({ deliverySlot: 'custom', specificTime: '14:35' });
for (const viewport of [{ width: 390, height: 844 }, { width: 768, height: 1024 }, { width: 1024, height: 768 }, { width: 1440, height: 900 }]) {
await page.setViewportSize(viewport);
await expect(page.getByRole('button', { name: '+ שמירת משלוח' })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}
await page.screenshot({ path: 'test-results/model-delivery-desktop.png', fullPage: true });
await page.getByRole('button', { name: 'לוח בקרה', exact: true }).click();
await expect(page.getByRole('region', { name: 'לוח משלוחים שבועי' })).toBeVisible();
await page.screenshot({ path: 'test-results/model-delivery-dashboard.png' });
await page.getByRole('button', { name: 'עלויות', exact: true }).click();
await expect(page.getByRole('heading', { name: 'עלויות משלוחים' })).toBeVisible();
await page.screenshot({ path: 'test-results/model-delivery-costs.png' });
await openDeliveryView(page);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole('button', { name: 'פתיחת תפריט' })).toBeVisible();
await page.getByRole('button', { name: 'פתיחת תפריט' }).click();
await expect(page.locator('.sidebar')).toHaveClass(/open/);
await page.getByRole('button', { name: 'משלוחים', exact: true }).click();
await expect(page.locator('.sidebar')).not.toHaveClass(/open/);
await page.screenshot({ path: 'test-results/model-delivery-mobile.png', fullPage: true });
expect(browserErrors).toEqual([]);
});
test('creates an offline customer and assigned address before its delivery, then syncs in order', async ({ page, context }) => {
await page.goto('/');
await openDeliveryView(page);
await expect(page.getByRole('combobox', { name: 'סוג משלוח', exact: true }).locator('option')).toHaveCount(3);
await context.setOffline(true);
await expect(page.locator('.connection')).toContainText('ללא חיבור לרשת');
await page.getByRole('button', { name: 'לקוחות', exact: true }).click();
const name = `לקוח בדיקה ${Date.now()}`;
await page.getByRole('button', { name: 'לקוח חדש', exact: true }).first().click();
await page.getByLabel('שם הלקוח').fill(name);
await page.getByLabel('איש קשר').fill('איש קשר לבדיקה');
await page.getByLabel('מספר טלפון').fill('050-1234567');
await page.getByLabel('בוקר', { exact: true }).check();
await page.getByRole('button', { name: 'יצירת לקוח', exact: true }).click();
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
await page.getByLabel('שם הכתובת').fill('ראשית');
await page.getByLabel('עיר', { exact: true }).fill('תל אביב');
await page.getByLabel('רחוב', { exact: true }).fill('הבדיקה');
await page.getByLabel('מספר', { exact: true }).fill('7');
await page.getByRole('button', { name: 'הוספת כתובת', exact: true }).click();
await expect(page.getByText('הבדיקה 7, תל אביב', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'משלוחים', exact: true }).click();
await page.getByRole('combobox', { name: 'לקוח', exact: true }).selectOption({ label: name });
await page.getByRole('combobox', { name: 'כתובת למשלוח', exact: true }).selectOption({ index: 1 });
await page.getByRole('button', { name: '+ שמירת משלוח', exact: true }).click();
const row = page.locator('.delivery-row').filter({ hasText: name });
await expect(row).toContainText('ממתין לסנכרון');
await context.setOffline(false);
await expect(row).toContainText('מסונכרן');
await expect(row).toContainText('#');
await expect(page.getByRole('alert')).toHaveCount(0);
});
test('edits and archives a customer while preserving related deliveries', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'לקוחות', exact: true }).click();
await page.getByRole('button', { name: 'לקוח חדש', exact: true }).first().click();
const name = `מרפאת פרופיל ${Date.now()}`;
await page.getByLabel('שם הלקוח').fill(name);
await page.getByLabel('איש קשר').fill('נועה כהן');
await page.getByLabel('מספר טלפון').fill('052-7654321');
await page.getByLabel('בוקר', { exact: true }).check();
await page.getByLabel('תחילת בוקר').fill('08:15');
await page.getByLabel('סיום בוקר').fill('11:45');
await page.getByLabel('הערות ללקוח').fill('כניסה מהחניון');
await page.getByRole('button', { name: 'יצירת לקוח' }).click();
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
const clientRow = page.locator('.client-row').filter({ hasText: name });
await expect(clientRow.locator('.sync-dot')).toHaveClass(/synced/);
await page.getByLabel('שם הכתובת').fill('מרפאה');
await page.getByLabel('עיר', { exact: true }).fill('חיפה');
await page.getByLabel('רחוב', { exact: true }).fill('הנמל');
await page.getByLabel('מספר', { exact: true }).fill('12');
await page.getByRole('button', { name: 'הוספת כתובת' }).click();
await expect(page.locator('.associated-row').filter({ hasText: 'הנמל 12, חיפה' }).last().locator('.sync-dot')).toHaveClass(/synced/);
await page.getByRole('button', { name: 'משלוחים', exact: true }).click();
await page.getByRole('combobox', { name: 'לקוח', exact: true }).selectOption({ label: name });
await page.getByRole('combobox', { name: 'כתובת למשלוח', exact: true }).selectOption({ index: 1 });
await page.getByRole('button', { name: '+ שמירת משלוח' }).click();
await expect(page.locator('.delivery-row').filter({ hasText: name })).toContainText('מסונכרן');
await page.getByRole('button', { name: 'לקוחות', exact: true }).click();
await clientRow.click();
await expect(page.getByText(/משלוח #/)).toBeVisible();
await expect(page.getByText('הנמל 12, חיפה', { exact: true })).toBeVisible();
await page.screenshot({ path: 'test-results/model-delivery-clients.png' });
await page.getByRole('button', { name: 'עריכה', exact: true }).click();
await page.getByLabel('איש קשר').fill('נועה לוי');
await page.getByRole('button', { name: 'שמירת שינויים' }).click();
await expect(page.locator('.client-summary strong').filter({ hasText: 'נועה לוי' })).toBeVisible();
await expect(clientRow.locator('.sync-dot')).toHaveClass(/synced/);
await page.getByRole('button', { name: 'הסרה', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('יישמרו ויישארו משויכים');
await page.getByRole('button', { name: 'העברה לארכיון' }).click();
await page.getByLabel('הצגת ארכיון').check();
await expect(clientRow).toBeVisible();
await clientRow.click();
await expect(page.getByText(/משלוח #/)).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect(page.locator('.sidebar')).not.toHaveClass(/open/);
await expect.poll(() => page.locator('.sidebar').evaluate(element => { const rect = element.getBoundingClientRect(); return rect.left >= innerWidth || rect.right <= 0; })).toBe(true);
await page.screenshot({ path: 'test-results/model-delivery-clients-mobile.png' });
await page.getByRole('button', { name: 'פתיחת תפריט' }).click();
await expect(page.locator('.sidebar')).toHaveClass(/open/);
await page.getByRole('button', { name: 'משלוחים', exact: true }).click();
await expect(page.getByRole('combobox', { name: 'לקוח', exact: true }).locator('option', { hasText: name })).toHaveCount(0);
});
test('keeps an offline edit across reload, resolves a concurrent edit explicitly, then cancels', async ({ page, context }) => {
const request = context.request;
await page.goto('/');
await openDeliveryView(page);
await expect(page.getByRole('combobox', { name: 'סוג משלוח', exact: true }).locator('option')).toHaveCount(3);
await page.evaluate(async () => { await navigator.serviceWorker.ready; });
await page.reload();
await openDeliveryView(page);
const conflictNotes = `Conflict browser test ${Date.now()}`;
await deliveryForm(page, conflictNotes);
await page.getByRole('button', { name: '+ שמירת משלוח', exact: true }).click();
await expect(page.locator('.detail-panel')).toContainText(conflictNotes);
const row = page.locator('.delivery-row[aria-pressed="true"]');
await expect(row).toContainText('מסונכרן');
const rowId = (await row.getAttribute('data-testid'))!;
const number = await row.locator('.row-top strong').innerText();
await context.setOffline(true);
await page.getByRole('button', { name: 'עריכת משלוח', exact: true }).click();
await page.getByLabel('הערות מעודכנות').fill('Offline edit to keep');
await page.getByRole('button', { name: 'שמירת שינויים', exact: true }).click();
await expect(page.getByTestId(rowId)).toContainText('ממתין לסנכרון');
await page.reload();
await openDeliveryView(page);
await page.getByTestId(rowId).click();
await expect(page.locator('.detail-panel')).toContainText('Offline edit to keep');
const session = await (await request.get('/api/auth/session')).json();
const headers = { 'x-model-request': '1', 'x-model-user': session.user.id };
const remote = await request.post('/api/sync/push', { headers, data: { operationId: randomUUID(), deviceId: randomUUID(), entityId: rowId.replace('delivery-', ''), baseVersion: 1, kind: 'delivery.update', data: { customerId: '20000000-0000-4000-8000-000000000001', addressId: '20000000-0000-4000-8000-000000000002', notes: 'Other device edit' } } });
expect(remote.ok()).toBe(true);
await context.setOffline(false);
const panel = page.getByRole('region', { name: 'פתרון התנגשות' });
await expect(panel).toBeVisible();
await expect(panel).toContainText('Offline edit to keep');
await expect(panel).toContainText('Other device edit');
await page.getByRole('button', { name: 'החלת השינוי שלי על הגרסה החדשה' }).click();
await expect(page.getByTestId(rowId)).toContainText('מסונכרן');
await expect(panel).toHaveCount(0);
await expect(page.locator('.detail-panel')).toContainText('Offline edit to keep');
await context.setOffline(true);
await page.getByRole('button', { name: 'ביטול משלוח', exact: true }).click();
await page.getByLabel('סיבת הביטול').fill('Cancelled in browser test');
await page.getByRole('button', { name: 'אישור ביטול משלוח', exact: true }).click();
await expect(page.getByTestId(rowId)).toContainText('מבוטל');
await page.reload();
await openDeliveryView(page);
await page.getByTestId(rowId).click();
await expect(page.getByTestId(rowId)).toContainText('ממתין לסנכרון');
await context.setOffline(false);
await expect(page.getByTestId(rowId)).toContainText('מסונכרן');
await expect(page.getByTestId(rowId)).toContainText(number);
await expect(page.getByRole('button', { name: 'עריכת משלוח', exact: true })).toBeDisabled();
await expect(page.locator('.detail-panel')).toContainText('15.02');
});
+2
View File
@@ -0,0 +1,2 @@
// Only used against model_delivery_test. This is not a default application account.
export const testAccount = { username: 'test.admin', password: 'Test-only-password-932!', displayName: 'משתמש בדיקה' };
+55
View File
@@ -0,0 +1,55 @@
// Test-only identity provider. Never imported or started by application code.
import { createServer } from 'node:http';
import { createHash, randomBytes } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { generateKeyPair, exportJWK, SignJWT } from 'jose';
export async function startMockOidc(port = 0) {
const { privateKey, publicKey } = await generateKeyPair('RS256');
const jwk = { ...await exportJWK(publicKey), kid: 'test-key', use: 'sig', alg: 'RS256' };
const codes = new Map(), refreshTokens = new Set(), accessTokens = new Set();
const controls = { badNonce: false, denyRefresh: false, refreshCount: 0, omitName: false, omitRefreshToken: false, subject: 'synology-test-user', groups: ['model-delivery-users'] };
let issuer = '';
const server = createServer(async (req, res) => {
const profile = { sub: controls.subject, username: 'test.sso', ...(controls.omitName ? {} : { name: 'טום בדיקה' }), email: 'test@example.invalid', groups: controls.groups };
const json = (data, status = 200) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(data)); };
const url = new URL(req.url, issuer);
try {
if (url.pathname === '/.well-known/openid-configuration') return json({ issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, userinfo_endpoint: `${issuer}/userinfo`, jwks_uri: `${issuer}/jwks`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], token_endpoint_auth_methods_supported: ['client_secret_post'], scopes_supported: ['openid', 'profile', 'email', 'groups', 'offline_access'], claims_supported: ['sub', 'username', 'name', 'email', 'groups'], code_challenge_methods_supported: ['S256'] });
if (url.pathname === '/jwks') return json({ keys: [jwk] });
if (url.pathname === '/authorize') {
const values = Object.fromEntries(url.searchParams);
if (values.client_id !== 'model-test' || values.redirect_uri !== 'http://localhost:4174/api/auth/callback' || values.code_challenge_method !== 'S256' || !values.code_challenge || !values.state || !values.nonce) return json({ error: 'invalid_request' }, 400);
const code = randomBytes(24).toString('hex'); codes.set(code, values);
const redirect = new URL(values.redirect_uri); redirect.searchParams.set('code', code); redirect.searchParams.set('state', values.state);
res.writeHead(302, { location: redirect.href }); return res.end();
}
if (url.pathname === '/token' && req.method === 'POST') {
let raw = ''; for await (const chunk of req) raw += chunk;
const fields = new URLSearchParams(raw);
if (fields.get('client_id') !== 'model-test' || fields.get('client_secret') !== 'mock-test-secret') return json({ error: 'invalid_client' }, 401);
let values;
if (fields.get('grant_type') === 'authorization_code') {
values = codes.get(fields.get('code')); codes.delete(fields.get('code'));
if (!values || values.redirect_uri !== fields.get('redirect_uri') || createHash('sha256').update(fields.get('code_verifier') ?? '').digest('base64url') !== values.code_challenge) return json({ error: 'invalid_grant' }, 400);
} else if (fields.get('grant_type') === 'refresh_token') {
if (controls.denyRefresh || !refreshTokens.delete(fields.get('refresh_token'))) return json({ error: 'invalid_grant' }, 400);
controls.refreshCount++;
} else return json({ error: 'unsupported_grant_type' }, 400);
const access_token = randomBytes(32).toString('hex'), refresh_token = randomBytes(32).toString('hex');
accessTokens.add(access_token); refreshTokens.add(refresh_token);
const id_token = values ? await new SignJWT({ ...profile, nonce: controls.badNonce ? 'wrong-nonce' : values.nonce }).setProtectedHeader({ alg: 'RS256', kid: jwk.kid }).setIssuer(issuer).setAudience('model-test').setIssuedAt().setExpirationTime('1h').sign(privateKey) : undefined;
return json({ access_token, ...(!controls.omitRefreshToken ? { refresh_token } : {}), id_token, token_type: 'Bearer', expires_in: 3600, scope: 'openid profile email groups offline_access' });
}
if (url.pathname === '/userinfo' && accessTokens.has(req.headers.authorization?.replace(/^Bearer /, ''))) return json(profile);
return json({ error: 'not_found' }, 404);
} catch { return json({ error: 'server_error' }, 500); }
});
await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
issuer = `http://localhost:${server.address().port}`;
return { issuer, controls, close: () => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const provider = await startMockOidc(4005);
console.log(`Test OIDC provider ready at ${provider.issuer}`);
}
+28
View File
@@ -0,0 +1,28 @@
import { expect, it } from 'vitest';
import type { LocalDelivery } from '../../src/db';
import { activeDeliveries, deliveriesForDate, deliveryDateKey, toDateKey, valueOf, weekDates } from '../../src/analytics';
function delivery(overrides: Partial<LocalDelivery> = {}): LocalDelivery {
return {
id: crypto.randomUUID(), customerId: crypto.randomUUID(), addressId: crypto.randomUUID(), deliveryTypeId: crypto.randomUUID(),
deliveryTypeVersion: 1, shipmentUnitHundredths: 100, unitPriceAgorot: 1000, totalPriceAgorot: 1000,
deliveryNumber: 1001, scheduledDate: '2026-09-06', deliverySlot: 'afternoon', specificTime: null, deliveryWindow: null, notes: '', status: 'created', version: 1,
createdAt: '2026-09-04T10:00:00.000Z', updatedAt: null, cancelledAt: null, cancellationReason: null,
deletedAt: null, syncState: 'synced', ...overrides,
};
}
it('builds an Israeli Sunday-to-Saturday week without timezone drift', () => {
const days = weekDates(new Date(2026, 8, 9, 23));
expect(days.map(toDateKey)).toEqual(['2026-09-06', '2026-09-07', '2026-09-08', '2026-09-09', '2026-09-10', '2026-09-11', '2026-09-12']);
});
it('uses the planned date and excludes cancelled deliveries from value totals', () => {
const legacy = delivery({ scheduledDate: null });
const active = delivery();
const cancelled = delivery({ status: 'cancelled', totalPriceAgorot: 5000 });
expect(deliveryDateKey(legacy)).toBe('2026-09-04');
expect(deliveriesForDate([legacy, active, cancelled], '2026-09-06')).toHaveLength(2);
expect(activeDeliveries([active, cancelled])).toEqual([active]);
expect(valueOf([active, cancelled])).toBe(1000);
});
+64
View File
@@ -0,0 +1,64 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, expect, it } from 'vitest';
import { deliverySchema, type Change } from '@model/shared';
import { ModelDatabase, changeDelivery, resolveConflict, storeChange } from '../../src/db';
import { AuthenticationError, runSync } from '../../src/sync';
let database: ModelDatabase;
const customerId = crypto.randomUUID(), addressId = crypto.randomUUID(), id = crypto.randomUUID();
const meta = { createdAt: new Date().toISOString(), deletedAt: null, version: 1 };
const original = deliverySchema.parse({ ...meta, id, customerId, addressId, deliverySlot: 'morning', specificTime: null, deliveryWindow: { start: '08:00', end: '11:00' }, deliveryTypeId: crypto.randomUUID(), deliveryTypeVersion: 1, shipmentUnitHundredths: 150, unitPriceAgorot: 1001, totalPriceAgorot: 1502, deliveryNumber: 1201, notes: 'Original', status: 'created' });
beforeEach(async () => {
database = new ModelDatabase(`changes-${crypto.randomUUID()}`);
await storeChange(database, { entityType: 'customer', record: { ...meta, id: customerId, name: 'Test' } });
await storeChange(database, { entityType: 'address', record: { ...meta, id: addressId, customerId, label: 'Main', address: 'Test address' } });
await storeChange(database, { entityType: 'delivery', record: original });
});
afterEach(async () => { await database.delete(); });
it('persists an offline edit with the original base version and immutable pricing', async () => {
await changeDelivery(id, 1, 'delivery.update', { customerId, addressId, deliverySlot: 'custom', specificTime: '14:35', deliveryWindow: null, notes: 'Offline edit' }, database);
database.close(); await database.open();
expect(await database.deliveries.get(id)).toMatchObject({ notes: 'Offline edit', deliverySlot: 'custom', specificTime: '14:35', deliveryWindow: null, syncState: 'pending', version: 1, deliveryNumber: 1201, totalPriceAgorot: 1502 });
expect((await database.outbox.toArray())[0]!.operation.baseVersion).toBe(1);
await expect(changeDelivery(id, 1, 'delivery.cancel', { reason: 'Too soon' }, database)).rejects.toThrow('סנכרון');
});
it('rejects a stale edit form instead of overwriting a newer pulled record', async () => {
await storeChange(database, { entityType: 'delivery', record: { ...original, version: 2, notes: 'Remote' } });
await expect(changeDelivery(id, 1, 'delivery.update', { customerId, addressId, notes: 'Stale form' }, database)).rejects.toThrow('השתנה');
expect(await database.outbox.count()).toBe(0);
});
it('cancels locally without removing the record, business number or snapshot', async () => {
await changeDelivery(id, 1, 'delivery.cancel', { reason: 'Customer requested' }, database);
expect(await database.deliveries.get(id)).toMatchObject({ status: 'cancelled', deliveryNumber: 1201, unitPriceAgorot: 1001, cancellationReason: 'Customer requested', syncState: 'pending' });
});
it('keeps the exact pending operation on 401 rather than marking it rejected', async () => {
await changeDelivery(id, 1, 'delivery.update', { customerId, addressId, notes: 'Waiting for login' }, database);
const entry = (await database.outbox.toArray())[0]!;
await expect(runSync(database, async () => Response.json({ code: 'AUTH_REQUIRED' }, { status: 401 }))).rejects.toBeInstanceOf(AuthenticationError);
expect((await database.outbox.toArray())[0]).toEqual(entry);
});
it('requires explicit resolution, resubmits with a new operation ID, and preserves the local intended patch', async () => {
await changeDelivery(id, 1, 'delivery.update', { customerId, addressId, notes: 'My edit' }, database);
const op = (await database.outbox.toArray())[0]!;
const server: Change = { entityType: 'delivery', record: { ...original, version: 2, notes: 'Another device' } };
await runSync(database, async url => url.endsWith('/push') ? Response.json({ code: 'VERSION_CONFLICT', current: server }, { status: 409 }) : Response.json({ changes: [{ sequence: '2', change: server }], nextCursor: '2', hasMore: false }));
expect((await database.deliveries.get(id))?.notes).toBe('My edit');
await expect(resolveConflict(op.operationId, 1, 'local', database)).rejects.toThrow('חדשה');
await resolveConflict(op.operationId, 2, 'local', database);
const replacement = (await database.outbox.toArray())[0]!;
expect(replacement.operationId).not.toBe(op.operationId);
expect(replacement.operation.baseVersion).toBe(2);
expect(replacement.operation.data).toMatchObject({ notes: 'My edit' });
});
it('accepts server cancellation explicitly and refuses to resurrect it', async () => {
await changeDelivery(id, 1, 'delivery.update', { customerId, addressId, notes: 'My edit' }, database);
const op = (await database.outbox.toArray())[0]!;
const server = { ...original, version: 2, status: 'cancelled' as const, cancellationReason: 'Closed', cancelledAt: new Date().toISOString() };
await database.outbox.update(op.queueId!, { state: 'error', serverConflict: { entityType: 'delivery', record: server } });
await expect(resolveConflict(op.operationId, 2, 'local', database)).rejects.toThrow();
await resolveConflict(op.operationId, 2, 'server', database);
expect(await database.outbox.count()).toBe(0);
expect(await database.deliveries.get(id)).toMatchObject({ status: 'cancelled', syncState: 'synced', version: 2 });
await storeChange(database, { entityType: 'delivery', record: original });
expect((await database.deliveries.get(id))?.status).toBe('cancelled');
});
+102
View File
@@ -0,0 +1,102 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { changeCustomer, ModelDatabase, resolveCustomerConflict, saveLocal, storeChange } from '../../src/db';
import { createSyncCoordinator, runSync, type Fetcher } from '../../src/sync';
import { operationSchema, totalPrice, type Change } from '@model/shared';
let db: ModelDatabase;
const customerId = crypto.randomUUID();
const addressId = crypto.randomUUID();
const typeId = crypto.randomUUID();
const meta = { version: 1, createdAt: new Date().toISOString(), deletedAt: null, updatedAt: null, cancelledAt: null, cancellationReason: null };
const deliveryData = { customerId, addressId, scheduledDate: null, deliverySlot: 'morning' as const, specificTime: null, deliveryWindow: { start: '08:00', end: '11:00' }, deliveryTypeId: typeId, deliveryTypeVersion: 1, shipmentUnitHundredths: 150, unitPriceAgorot: 1001, notes: 'בדיקה' };
beforeEach(async () => {
db = new ModelDatabase(`test-${crypto.randomUUID()}`);
await storeChange(db, { entityType: 'customer', record: { id: customerId, name: 'Test', ...meta } });
await storeChange(db, { entityType: 'address', record: { id: addressId, customerId, label: 'Main', address: 'Test address', ...meta } });
});
afterEach(async () => { await db.delete(); });
describe('durable local writes and recovery', () => {
it('honors reconnect while an earlier offline request is still failing', async () => {
let rejectFirst!: (error: Error) => void;
const firstRequest = new Promise<void>((_resolve, reject) => { rejectFirst = reject; });
let attempts = 0;
const sync = createSyncCoordinator(async () => { if (++attempts === 1) await firstRequest; });
const startup = sync();
const reconnect = sync();
rejectFirst(new Error('The old offline request failed'));
await expect(startup).resolves.toBeUndefined();
await expect(reconnect).resolves.toBeUndefined();
expect(attempts).toBe(2);
});
it('saves entity and outbox offline, survives reopen and reuses the same operation after lost response', async () => {
const id = await saveLocal('delivery.create', deliveryData, db);
const [entry] = await db.outbox.toArray();
expect(await db.deliveries.get(id)).toMatchObject({ deliveryNumber: null, syncState: 'pending', totalPriceAgorot: 1502, deliverySlot: 'morning', deliveryWindow: { start: '08:00', end: '11:00' } });
db.close(); await db.open();
const attempts: string[] = [];
const change: Change = { entityType: 'delivery', record: { ...deliveryData, ...meta, id, deliveryNumber: 1001, totalPriceAgorot: 1502, status: 'created' } };
const lostResponse: Fetcher = async (_url, init) => { attempts.push(JSON.parse(String(init?.body)).operationId); throw new Error('Response lost after commit'); };
await expect(runSync(db, lostResponse)).rejects.toThrow();
expect(await db.outbox.count()).toBe(1);
const replay: Fetcher = async (url, init) => {
if (url.endsWith('/push')) { attempts.push(JSON.parse(String(init?.body)).operationId); return Response.json({ change, replayed: true }); }
expect(url).toContain('cursor=0');
return Response.json({ changes: [{ sequence: '1', change }], nextCursor: '1', hasMore: false });
};
await runSync(db, replay);
expect(attempts).toEqual([entry!.operationId, entry!.operationId]);
expect(await db.outbox.count()).toBe(0);
expect(await db.deliveries.get(id)).toMatchObject({ id, deliveryNumber: 1001, syncState: 'synced' });
});
it('rolls back the local entity if the outbox insert fails', async () => {
db.outbox.hook('creating', () => { throw new Error('Disk full'); });
await expect(saveLocal('delivery.create', deliveryData, db)).rejects.toThrow('Disk full');
expect(await db.deliveries.count()).toBe(0);
expect(await db.outbox.count()).toBe(0);
});
it('rejects addresses assigned to another customer without saving an operation', async () => {
await expect(saveLocal('delivery.create', { ...deliveryData, customerId: crypto.randomUUID() }, db)).rejects.toThrow();
expect(await db.outbox.count()).toBe(0);
});
it('preserves a conflicting draft when the pull feed contains the server copy', async () => {
const id = await saveLocal('delivery.create', deliveryData, db);
const change: Change = { entityType: 'delivery', record: { ...deliveryData, ...meta, id, notes: 'Different server copy', deliveryNumber: 2001, totalPriceAgorot: 1502, status: 'created' } };
await runSync(db, async url => url.endsWith('/push') ? Response.json({ code: 'VERSION_CONFLICT', current: change }, { status: 409 }) : Response.json({ changes: [{ sequence: '1', change }], nextCursor: '1', hasMore: false }));
expect(await db.deliveries.get(id)).toMatchObject({ notes: 'בדיקה', syncState: 'error', deliveryNumber: null });
expect((await db.outbox.toArray())[0]?.serverConflict).toEqual(change);
});
it('applies tombstones and paginated changes with a durable cursor', async () => {
const first: Change = { entityType: 'customer', record: { id: customerId, name: 'Deleted', createdAt: meta.createdAt, version: 2, deletedAt: meta.createdAt } };
const second: Change = { entityType: 'address', record: { id: addressId, customerId, label: 'Deleted', address: 'Test', createdAt: meta.createdAt, version: 2, deletedAt: meta.createdAt } };
const cursors: string[] = [];
await runSync(db, async url => {
cursors.push(url);
return Response.json(url.endsWith('=0') ? { changes: [{ sequence: '5', change: first }], nextCursor: '5', hasMore: true } : { changes: [{ sequence: '8', change: second }], nextCursor: '8', hasMore: false });
});
expect(cursors).toEqual(['/api/sync/pull?cursor=0', '/api/sync/pull?cursor=5']);
expect((await db.customers.get(customerId))?.deletedAt).toBe(meta.createdAt);
expect((await db.meta.get('cursor'))?.value).toBe('8');
});
it('rejects nonzero create versions and rounds fixed-point totals', () => {
expect(operationSchema.safeParse({ baseVersion: 1, kind: 'delivery.create' }).success).toBe(false);
expect(operationSchema.safeParse({ operationId: crypto.randomUUID(), deviceId: crypto.randomUUID(), entityId: crypto.randomUUID(), baseVersion: 0, kind: 'delivery.create', data: { ...deliveryData, deliverySlot: 'custom', specificTime: null, deliveryWindow: null } }).success).toBe(false);
expect(totalPrice(101, 150)).toBe(152);
});
it('stores customer edits and archives atomically, with explicit conflict resolution', async () => {
const details = { name: 'Updated clinic', contactName: 'תום רוף', phone: '050-1234567', comments: 'Ring at reception', deliveryWindows: { morning: { start: '08:30', end: '11:45' }, afternoon: null, lateNight: null } };
await changeCustomer(customerId, 1, 'customer.update', details, db);
const pending = (await db.outbox.toArray())[0]!;
expect(await db.customers.get(customerId)).toMatchObject({ ...details, syncState: 'pending' });
const server: Change = { entityType: 'customer', record: { id: customerId, ...details, name: 'Server clinic', version: 2, createdAt: meta.createdAt, deletedAt: null, updatedAt: meta.createdAt } };
await db.outbox.update(pending.queueId!, { state: 'error', serverConflict: server });
await resolveCustomerConflict(pending.operationId, 2, 'local', db);
const retry = (await db.outbox.toArray())[0]!;
expect(retry.operation).toMatchObject({ kind: 'customer.update', baseVersion: 2, data: details });
await db.outbox.clear();
await storeChange(db, server);
await changeCustomer(customerId, 2, 'customer.remove', {}, db);
expect((await db.customers.get(customerId))?.deletedAt).toBeTruthy();
expect((await db.outbox.toArray())[0]?.operation.kind).toBe('customer.remove');
});
});