import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import LibDashStore from '../src/index.js'; import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; async function run() { console.log('--- Testing posterminalsyncqueue table and sync queue flow ---'); const tempDir = mkdtempSync(join(tmpdir(), 'test-syncqueue-')); const dbFile = join(tempDir, 'test.db'); const storageDir = join(tempDir, 'storage'); const provider = new BetterSqlite3Provider({ filename: dbFile, storageDir, }); await provider.initialize(); const store = await new LibDashStore(provider); const clearQueue = () => { provider.db.prepare('DELETE FROM posterminalsyncqueue').run(); }; const getQueue = () => provider.db .prepare('SELECT posterminal_id, entity_type, entity_id FROM posterminalsyncqueue ORDER BY id ASC') .all(); try { // 1. Verify posterminalsyncqueue table exists and has expected columns console.log('1. Checking posterminalsyncqueue schema...'); const tableInfo = provider.db.pragma('table_info("posterminalsyncqueue")'); const colNames = tableInfo.map((c) => c.name); console.log('posterminalsyncqueue columns:', colNames); assert.deepEqual(colNames, [ 'id', 'posterminal_id', 'entity_type', 'entity_id', ]); // 2. Setup company & 2 POS terminals + another company with 1 terminal (to test company scoping) console.log('2. Setting up base data with 2 terminals in company 1 and 1 terminal in company 2...'); const googleAuthId = await store.google_auth.add('syncqueue-tester@example.com'); const company1 = await store.company.create({ name: 'SyncQueue Corp 1', iso639: 'de', taxrate: 19, reducedtaxrate: 7, googleAuthId, }); const company2 = await store.company.create({ name: 'SyncQueue Corp 2', iso639: 'de', taxrate: 19, reducedtaxrate: 7, googleAuthId, }); await store.google_auth.setSelectedCompany(googleAuthId, company2.id); const otherTerminal = await store.posterminal.upsert(googleAuthId, { name: 'Other Corp Terminal', pairingpin: '9999', }); await store.google_auth.setSelectedCompany(googleAuthId, company1.id); const term1 = await store.posterminal.upsert(googleAuthId, { name: 'POS Terminal 1', pairingpin: '1111', }); const term2 = await store.posterminal.upsert(googleAuthId, { name: 'POS Terminal 2', pairingpin: '2222', }); // Helper to assert that both term1 and term2 received the expected entity_type + entity_id, and otherTerminal did not const assertQueuedForBothTerminals = (entityType, entityId, label) => { const rows = getQueue(); const matching = rows.filter((r) => r.entity_type === entityType && r.entity_id === entityId); const termIds = matching.map((r) => r.posterminal_id).sort((a, b) => a - b); assert.deepEqual( termIds, [term1.id, term2.id], `Expected [${term1.id}, ${term2.id}] for ${label} (${entityType}:${entityId}), got ${JSON.stringify(rows)}`, ); const otherRows = rows.filter((r) => r.posterminal_id === otherTerminal.id); assert.equal(otherRows.length, 0, `Other company terminal should not receive rows (${label})`); }; // 3. Test Category operations console.log('3. Testing category operations...'); clearQueue(); const catA = await store.category.createUnderParent({ googleAuthId, parentId: company1.rootCategoryId, name: 'Category A', }); assertQueuedForBothTerminals('category', catA.id, 'category.createUnderParent'); clearQueue(); await store.category.rename({ googleAuthId, id: catA.id, name: 'Category A Renamed', }); assertQueuedForBothTerminals('category', catA.id, 'category.rename'); clearQueue(); const catLeafId = await store.category.ensureByPath({ googleAuthId, parentId: company1.rootCategoryId, path: 'Category B | Subcategory B1', }); assertQueuedForBothTerminals('category', catLeafId, 'category.ensureByPath'); const catBRow = provider.db .prepare('SELECT c.id FROM category c WHERE c.parent_id = ? AND c.id != ?') .get(company1.rootCategoryId, catA.id); clearQueue(); await store.category.order({ googleAuthId, parentId: company1.rootCategoryId, categoryIds: [catBRow.id, catA.id], }); assertQueuedForBothTerminals('category', catA.id, 'category.order'); assertQueuedForBothTerminals('category', catBRow.id, 'category.order (sibling)'); clearQueue(); await store.category.move({ googleAuthId, id: catLeafId, parentId: catA.id, }); assertQueuedForBothTerminals('category', catLeafId, 'category.move'); const prop = await store.propertyname.create(googleAuthId, { name: 'Color', exclusive: false }); clearQueue(); await store.categoryproperty.setByCategoryId(googleAuthId, catA.id, [ { propertyId: prop.id, value: 'Red' }, ]); assertQueuedForBothTerminals('category', catA.id, 'categoryproperty.setByCategoryId'); clearQueue(); await store.categoryproperty.delete(googleAuthId, catA.id, prop.id); assertQueuedForBothTerminals('category', catA.id, 'categoryproperty.delete'); const attr = await store.attributename.create(googleAuthId, { name: 'Size', exclusive: true }); const attrVal = await store.attributevalue.create(googleAuthId, attr.id, { value: 'XL' }); clearQueue(); await store.categoryattributevalue.setByCategoryId(googleAuthId, catA.id, [attrVal.id]); assertQueuedForBothTerminals('category', catA.id, 'categoryattributevalue.setByCategoryId'); // 4. Test Article operations console.log('4. Testing article operations...'); clearQueue(); const art1 = await store.article.createUnderParent({ googleAuthId, parentId: catA.id, name: 'Cola 0.33L', }); assertQueuedForBothTerminals('article', art1.id, 'article.createUnderParent'); clearQueue(); await store.article.updateBySku(googleAuthId, art1.sku, { name: 'Cola Classic 0.33L' }); assertQueuedForBothTerminals('article', art1.id, 'article.updateBySku'); clearQueue(); await store.article.setPricesBySku(googleAuthId, art1.sku, [{ net: 1.5, taxRate: 19 }]); assertQueuedForBothTerminals('article', art1.id, 'article.setPricesBySku'); clearQueue(); await store.article.setEansBySku(googleAuthId, art1.sku, ['4001234567890']); assertQueuedForBothTerminals('article', art1.id, 'article.setEansBySku'); const brand = await store.brand.create(googleAuthId, { name: 'ColaBrand' }); clearQueue(); await store.article.setBrandIdBySku(googleAuthId, art1.sku, brand.id); assertQueuedForBothTerminals('article', art1.id, 'article.setBrandIdBySku'); clearQueue(); await store.brand.update(googleAuthId, brand.id, { name: 'ColaBrand Updated' }); assertQueuedForBothTerminals('article', art1.id, 'brand.update'); clearQueue(); await store.article.setWeightBySku(googleAuthId, art1.sku, 0.35); assertQueuedForBothTerminals('article', art1.id, 'article.setWeightBySku'); const wh = await store.warehouse.create(googleAuthId, { name: 'Main Warehouse' }); clearQueue(); await store.article.setTrackStockBySku(googleAuthId, art1.sku, true); assertQueuedForBothTerminals('article', art1.id, 'article.setTrackStockBySku'); clearQueue(); await store.stock.adjust(googleAuthId, { sku: art1.sku, warehouseId: wh.id, amount: 10, text: 'Initial stock', }); assertQueuedForBothTerminals('article', art1.id, 'stock.adjust'); clearQueue(); await store.article.setCategoryIdsBySku(googleAuthId, art1.sku, [catA.id, catLeafId]); assertQueuedForBothTerminals('article', art1.id, 'article.setCategoryIdsBySku'); clearQueue(); await store.articleproperty.setByArticleSku(googleAuthId, art1.sku, [ { propertyId: prop.id, value: 'Dark' }, ]); assertQueuedForBothTerminals('article', art1.id, 'articleproperty.setByArticleSku'); clearQueue(); await store.articleproperty.delete(googleAuthId, art1.sku, prop.id); assertQueuedForBothTerminals('article', art1.id, 'articleproperty.delete'); clearQueue(); await store.articleattributevalue.setByArticleSku(googleAuthId, art1.sku, [attrVal.id]); assertQueuedForBothTerminals('article', art1.id, 'articleattributevalue.setByArticleSku'); clearQueue(); await store.attributevalue.update(googleAuthId, attrVal.id, { value: 'XXL' }); assertQueuedForBothTerminals('article', art1.id, 'attributevalue.update'); // Optionslist & Stückliste const pfand = await store.article.createUnderParent({ googleAuthId, parentId: catA.id, name: 'Pfand 0.25', }); clearQueue(); await store.optionslist.setOptionsByArticleSku(googleAuthId, art1.sku, [ { sku: pfand.sku, count: 1, mandated: true }, ]); assertQueuedForBothTerminals('article', art1.sku ? art1.id : 0, 'optionslist.setOptionsByArticleSku'); // Updating option article price should also enqueue parent article! clearQueue(); await store.article.setPricesBySku(googleAuthId, pfand.sku, [{ net: 0.25, taxRate: 19 }]); assertQueuedForBothTerminals('article', pfand.id, 'setPricesBySku (option itself)'); assertQueuedForBothTerminals('article', art1.id, 'setPricesBySku (option parent)'); const bundle = await store.article.createUnderParent({ googleAuthId, parentId: catA.id, name: 'Cola Crate', }); clearQueue(); await store.articlelist.setItemsByArticleSku(googleAuthId, bundle.sku, [ { sku: art1.sku, count: 6 }, ]); assertQueuedForBothTerminals('article', bundle.id, 'articlelist.setItemsByArticleSku'); // Adjusting component stock should also enqueue Stückliste parent! clearQueue(); await store.stock.adjust(googleAuthId, { sku: art1.sku, warehouseId: wh.id, amount: 2, text: 'Restock component', }); assertQueuedForBothTerminals('article', art1.id, 'stock.adjust (component)'); assertQueuedForBothTerminals('article', bundle.id, 'stock.adjust (Stückliste parent)'); // 5. Test Customer Group operations console.log('5. Testing customergroup operations...'); clearQueue(); const cg1 = await store.customergroup.create(googleAuthId, { name: 'B2B' }); assertQueuedForBothTerminals('customergroup', cg1.id, 'customergroup.create'); clearQueue(); await store.customergroup.update(googleAuthId, cg1.id, { name: 'B2B Gold' }); assertQueuedForBothTerminals('customergroup', cg1.id, 'customergroup.update'); const allGroups = await store.customergroup.list(googleAuthId); clearQueue(); await store.customergroup.order(googleAuthId, { customergroupIds: allGroups.map((g) => g.id).reverse(), }); assertQueuedForBothTerminals('customergroup', cg1.id, 'customergroup.order'); // 6. Test Customer (Entity + Address + Contact) operations console.log('6. Testing customer (entity, entityaddress, entitycontact) operations...'); clearQueue(); const cust = await store.entity.create(googleAuthId, { customergroupId: cg1.id, name: 'Doe', firstName: 'John', }); assertQueuedForBothTerminals('customer', cust.id, 'entity.create'); clearQueue(); await store.entity.update(googleAuthId, cust.id, { firstName: 'Johnny' }); assertQueuedForBothTerminals('customer', cust.id, 'entity.update'); clearQueue(); const addr = await store.entityaddress.create(googleAuthId, cust.id, { addressType: 'billing', street: 'Main St 1', city: 'Berlin', }); assertQueuedForBothTerminals('customer', cust.id, 'entityaddress.create'); clearQueue(); await store.entityaddress.update(googleAuthId, cust.id, addr.id, { city: 'Munich', }); assertQueuedForBothTerminals('customer', cust.id, 'entityaddress.update'); clearQueue(); await store.entityaddress.delete(googleAuthId, cust.id, addr.id); assertQueuedForBothTerminals('customer', cust.id, 'entityaddress.delete'); clearQueue(); const contact = await store.entitycontact.create(googleAuthId, cust.id, { contactType: 'email', contact: 'john@example.com', }); assertQueuedForBothTerminals('customer', cust.id, 'entitycontact.create'); clearQueue(); await store.entitycontact.update(googleAuthId, cust.id, contact.id, { contact: 'johnny@example.com', }); assertQueuedForBothTerminals('customer', cust.id, 'entitycontact.update'); clearQueue(); await store.entitycontact.delete(googleAuthId, cust.id, contact.id); assertQueuedForBothTerminals('customer', cust.id, 'entitycontact.delete'); // 7. Test CHECK constraint on entity_type console.log('7. Testing entity_type CHECK constraint...'); assert.throws(() => { provider.db .prepare('INSERT INTO posterminalsyncqueue (posterminal_id, entity_type, entity_id) VALUES (?, ?, ?)') .run(term1.id, 'invalid_type', 1); }, /CHECK constraint failed/); // 8. Test POS Sync Cursor (posterminalsyncqueue.id) across counts and lists console.log('8. Testing POS Sync Cursor (posterminalsyncqueue.id) for counts & lists...'); // Create a fresh terminal (term3) and enable all categories on it to verify full initial sync + delta cursor const term3 = await store.posterminal.upsert(googleAuthId, { name: 'POS Terminal 3', pairingpin: '3333', }); await store.posterminal.setOnAll(googleAuthId, term3.id); // Initial sync with cursor = 0 const initProductCount = await store.posterminal.getProductCount({ posterminalId: term3.id, cursor: 0 }); const initCategoryCount = await store.posterminal.getCategoryCount({ posterminalId: term3.id, cursor: 0 }); const initCustomerCount = await store.posterminal.getCustomerCount({ posterminalId: term3.id, cursor: 0 }); const initCustomerGroupCount = await store.posterminal.getCustomerGroupCount({ posterminalId: term3.id, cursor: 0 }); const initCompositeCount = await store.posterminal.getCompositeProductCount({ posterminalId: term3.id, cursor: 0 }); assert.ok(initProductCount > 0, `Expected initProductCount > 0, got ${initProductCount}`); assert.ok(initCategoryCount > 0, `Expected initCategoryCount > 0, got ${initCategoryCount}`); assert.ok(initCustomerCount > 0, `Expected initCustomerCount > 0, got ${initCustomerCount}`); assert.ok(initCustomerGroupCount > 0, `Expected initCustomerGroupCount > 0, got ${initCustomerGroupCount}`); assert.ok(initCompositeCount > 0, `Expected initCompositeCount > 0, got ${initCompositeCount}`); const products = await store.posterminal.getProductList({ posterminalId: term3.id, cursor: 0, limit: 100 }); const categories = await store.posterminal.getCategoryList({ posterminalId: term3.id, cursor: 0, limit: 100 }); const customers = await store.posterminal.getCustomerList({ posterminalId: term3.id, cursor: 0, limit: 100 }); const customerGroups = await store.posterminal.getCustomerGroupList({ posterminalId: term3.id, cursor: 0, limit: 100 }); assert.equal(products.length, initProductCount); assert.equal(categories.length, initCategoryCount); assert.equal(customers.length, initCustomerCount); assert.equal(customerGroups.length, initCustomerGroupCount); // Advance cursors to max lastChanged const maxProductCursor = Math.max(...products.map((p) => Number(p.lastChanged))); const maxCategoryCursor = Math.max(...categories.map((c) => Number(c.lastChanged))); const maxCustomerCursor = Math.max(...customers.map((c) => Number(c.lastChanged))); const maxCustomerGroupCursor = Math.max(...customerGroups.map((cg) => Number(cg.lastChanged))); // Verify all counts and lists are 0 at the current cursors assert.equal(await store.posterminal.getProductCount({ posterminalId: term3.id, cursor: maxProductCursor }), 0); assert.equal(await store.posterminal.getCategoryCount({ posterminalId: term3.id, cursor: maxCategoryCursor }), 0); assert.equal(await store.posterminal.getCustomerCount({ posterminalId: term3.id, cursor: maxCustomerCursor }), 0); assert.equal(await store.posterminal.getCustomerGroupCount({ posterminalId: term3.id, cursor: maxCustomerGroupCursor }), 0); assert.equal(await store.posterminal.getCompositeProductCount({ posterminalId: term3.id, cursor: maxProductCursor }), 0); assert.equal((await store.posterminal.getProductList({ posterminalId: term3.id, cursor: maxProductCursor })).length, 0); assert.equal((await store.posterminal.getCategoryList({ posterminalId: term3.id, cursor: maxCategoryCursor })).length, 0); assert.equal((await store.posterminal.getCustomerList({ posterminalId: term3.id, cursor: maxCustomerCursor })).length, 0); assert.equal((await store.posterminal.getCustomerGroupList({ posterminalId: term3.id, cursor: maxCustomerGroupCursor })).length, 0); // Now touch one article twice, one category, one customer, one customergroup await store.article.updateBySku(googleAuthId, art1.sku, { name: 'Cola Classic v2' }); await store.article.updateBySku(googleAuthId, art1.sku, { name: 'Cola Classic v3' }); await store.category.rename({ googleAuthId, id: catA.id, name: 'Category A v2' }); await store.entity.update(googleAuthId, cust.id, { firstName: 'John v2' }); await store.customergroup.update(googleAuthId, cg1.id, { name: 'B2B Platinum' }); // Even though art1 was updated twice, count should be 1 (deduplicated by entity_id with MAX(id) > cursor) assert.equal(await store.posterminal.getProductCount({ posterminalId: term3.id, cursor: maxProductCursor }), 1); assert.equal(await store.posterminal.getCategoryCount({ posterminalId: term3.id, cursor: maxCategoryCursor }), 1); assert.equal(await store.posterminal.getCustomerCount({ posterminalId: term3.id, cursor: maxCustomerCursor }), 1); assert.equal(await store.posterminal.getCustomerGroupCount({ posterminalId: term3.id, cursor: maxCustomerGroupCursor }), 1); const deltaProducts = await store.posterminal.getProductList({ posterminalId: term3.id, cursor: maxProductCursor }); const deltaCategories = await store.posterminal.getCategoryList({ posterminalId: term3.id, cursor: maxCategoryCursor }); const deltaCustomers = await store.posterminal.getCustomerList({ posterminalId: term3.id, cursor: maxCustomerCursor }); const deltaCustomerGroups = await store.posterminal.getCustomerGroupList({ posterminalId: term3.id, cursor: maxCustomerGroupCursor }); assert.equal(deltaProducts.length, 1); assert.equal(deltaProducts[0]._id, String(art1.id)); assert.ok(Number(deltaProducts[0].lastChanged) > maxProductCursor); assert.equal(deltaCategories.length, 1); assert.equal(deltaCategories[0]._id, String(catA.id)); assert.ok(Number(deltaCategories[0].lastChanged) > maxCategoryCursor); assert.equal(deltaCustomers.length, 1); assert.equal(deltaCustomers[0].id, String(cust.id)); assert.ok(Number(deltaCustomers[0].lastChanged) > maxCustomerCursor); assert.equal(deltaCustomerGroups.length, 1); assert.equal(deltaCustomerGroups[0].customerGroupId, String(cg1.id)); assert.ok(Number(deltaCustomerGroups[0].lastChanged) > maxCustomerGroupCursor); console.log('✅ ALL POSTERMINALSYNCQUEUE TESTS PASSED'); } finally { try { rmSync(tempDir, { recursive: true, force: true }); } catch {} } } run().catch((err) => { console.error('Test failed:', err); process.exit(1); });