Implements offline-first calendar sync infrastructure
Adds IndexedDB and operation queue for robust offline synchronization Introduces SyncManager to handle background data synchronization Supports local event operations with automatic remote sync queuing Enhances application reliability and user experience in low/no connectivity scenarios
This commit is contained in:
parent
9c765b35ab
commit
e7011526e3
20 changed files with 3822 additions and 57 deletions
452
test/integrationtesting/test-init.js
Normal file
452
test/integrationtesting/test-init.js
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
/**
|
||||
* Test Initialization Script
|
||||
* Standalone initialization for test pages without requiring full calendar DOM
|
||||
*/
|
||||
|
||||
// IndexedDB Service (simplified standalone version)
|
||||
class TestIndexedDBService {
|
||||
constructor() {
|
||||
this.DB_NAME = 'CalendarDB_Test'; // Separate test database
|
||||
this.DB_VERSION = 1;
|
||||
this.EVENTS_STORE = 'events';
|
||||
this.QUEUE_STORE = 'operationQueue';
|
||||
this.SYNC_STATE_STORE = 'syncState';
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
|
||||
// Create events store
|
||||
if (!db.objectStoreNames.contains(this.EVENTS_STORE)) {
|
||||
const eventStore = db.createObjectStore(this.EVENTS_STORE, { keyPath: 'id' });
|
||||
eventStore.createIndex('start', 'start', { unique: false });
|
||||
eventStore.createIndex('end', 'end', { unique: false });
|
||||
eventStore.createIndex('syncStatus', 'syncStatus', { unique: false });
|
||||
}
|
||||
|
||||
// Create operation queue store
|
||||
if (!db.objectStoreNames.contains(this.QUEUE_STORE)) {
|
||||
const queueStore = db.createObjectStore(this.QUEUE_STORE, { keyPath: 'id', autoIncrement: true });
|
||||
queueStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
queueStore.createIndex('eventId', 'eventId', { unique: false });
|
||||
}
|
||||
|
||||
// Create sync state store
|
||||
if (!db.objectStoreNames.contains(this.SYNC_STATE_STORE)) {
|
||||
db.createObjectStore(this.SYNC_STATE_STORE, { keyPath: 'key' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getAllEvents() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.EVENTS_STORE], 'readonly');
|
||||
const store = transaction.objectStore(this.EVENTS_STORE);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const events = request.result.map(event => ({
|
||||
...event,
|
||||
start: new Date(event.start),
|
||||
end: new Date(event.end)
|
||||
}));
|
||||
resolve(events);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getEvent(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.EVENTS_STORE], 'readonly');
|
||||
const store = transaction.objectStore(this.EVENTS_STORE);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const event = request.result;
|
||||
if (event) {
|
||||
event.start = new Date(event.start);
|
||||
event.end = new Date(event.end);
|
||||
}
|
||||
resolve(event || null);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveEvent(event) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.EVENTS_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(this.EVENTS_STORE);
|
||||
const eventToSave = {
|
||||
...event,
|
||||
start: event.start instanceof Date ? event.start.toISOString() : event.start,
|
||||
end: event.end instanceof Date ? event.end.toISOString() : event.end
|
||||
};
|
||||
const request = store.put(eventToSave);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEvent(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.EVENTS_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(this.EVENTS_STORE);
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async addToQueue(operation) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.QUEUE_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(this.QUEUE_STORE);
|
||||
const request = store.add(operation);
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getQueue() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.QUEUE_STORE], 'readonly');
|
||||
const store = transaction.objectStore(this.QUEUE_STORE);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async removeFromQueue(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.QUEUE_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(this.QUEUE_STORE);
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearQueue() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.QUEUE_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(this.QUEUE_STORE);
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Operation Queue (simplified standalone version)
|
||||
class TestOperationQueue {
|
||||
constructor(indexedDB) {
|
||||
this.indexedDB = indexedDB;
|
||||
}
|
||||
|
||||
async enqueue(operation) {
|
||||
await this.indexedDB.addToQueue(operation);
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
return await this.indexedDB.getQueue();
|
||||
}
|
||||
|
||||
async remove(id) {
|
||||
await this.indexedDB.removeFromQueue(id);
|
||||
}
|
||||
|
||||
async clear() {
|
||||
await this.indexedDB.clearQueue();
|
||||
}
|
||||
|
||||
async incrementRetryCount(operationId) {
|
||||
const queue = await this.getAll();
|
||||
const operation = queue.find(op => op.id === operationId);
|
||||
if (operation) {
|
||||
operation.retryCount = (operation.retryCount || 0) + 1;
|
||||
await this.indexedDB.removeFromQueue(operationId);
|
||||
await this.indexedDB.addToQueue(operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple EventManager for tests
|
||||
class TestEventManager {
|
||||
constructor(indexedDB, queue) {
|
||||
this.indexedDB = indexedDB;
|
||||
this.queue = queue;
|
||||
}
|
||||
|
||||
async getAllEvents() {
|
||||
return await this.indexedDB.getAllEvents();
|
||||
}
|
||||
|
||||
async getEvent(id) {
|
||||
return await this.indexedDB.getEvent(id);
|
||||
}
|
||||
|
||||
async addEvent(eventData) {
|
||||
const id = eventData.id || `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const syncStatus = eventData.syncStatus || 'pending';
|
||||
|
||||
const newEvent = {
|
||||
...eventData,
|
||||
id,
|
||||
syncStatus
|
||||
};
|
||||
|
||||
await this.indexedDB.saveEvent(newEvent);
|
||||
|
||||
if (syncStatus === 'pending') {
|
||||
await this.queue.enqueue({
|
||||
type: 'create',
|
||||
eventId: id,
|
||||
data: newEvent,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
async updateEvent(id, updates) {
|
||||
const event = await this.indexedDB.getEvent(id);
|
||||
if (!event) return null;
|
||||
|
||||
const updatedEvent = { ...event, ...updates, syncStatus: 'pending' };
|
||||
await this.indexedDB.saveEvent(updatedEvent);
|
||||
|
||||
await this.queue.enqueue({
|
||||
type: 'update',
|
||||
eventId: id,
|
||||
data: updates,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
});
|
||||
|
||||
return updatedEvent;
|
||||
}
|
||||
|
||||
async deleteEvent(id) {
|
||||
await this.indexedDB.deleteEvent(id);
|
||||
await this.queue.enqueue({
|
||||
type: 'delete',
|
||||
eventId: id,
|
||||
data: null,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal SyncManager for tests with mock API simulation
|
||||
class TestSyncManager {
|
||||
constructor(queue, indexedDB) {
|
||||
this.queue = queue;
|
||||
this.indexedDB = indexedDB;
|
||||
this.isOnline = navigator.onLine;
|
||||
this.maxRetries = 5;
|
||||
this.setupNetworkListeners();
|
||||
}
|
||||
|
||||
setupNetworkListeners() {
|
||||
window.addEventListener('online', () => {
|
||||
this.isOnline = true;
|
||||
console.log('[TestSyncManager] Network online');
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false;
|
||||
console.log('[TestSyncManager] Network offline');
|
||||
});
|
||||
}
|
||||
|
||||
async triggerManualSync() {
|
||||
console.log('[TestSyncManager] Manual sync triggered');
|
||||
|
||||
// Check if online before syncing
|
||||
if (!this.isOnline) {
|
||||
console.warn('[TestSyncManager] ⚠️ Cannot sync - offline mode');
|
||||
throw new Error('Cannot sync while offline');
|
||||
}
|
||||
|
||||
const queueItems = await this.queue.getAll();
|
||||
console.log(`[TestSyncManager] Queue has ${queueItems.length} items`);
|
||||
|
||||
if (queueItems.length === 0) {
|
||||
console.log('[TestSyncManager] Queue is empty - nothing to sync');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Process each operation
|
||||
for (const operation of queueItems) {
|
||||
await this.processOperation(operation);
|
||||
}
|
||||
|
||||
return queueItems;
|
||||
}
|
||||
|
||||
async processOperation(operation) {
|
||||
console.log(`[TestSyncManager] Processing operation ${operation.id} (retry: ${operation.retryCount})`);
|
||||
|
||||
// Check if max retries exceeded
|
||||
if (operation.retryCount >= this.maxRetries) {
|
||||
console.error(`[TestSyncManager] Max retries (${this.maxRetries}) exceeded for operation ${operation.id}`);
|
||||
await this.queue.remove(operation.id);
|
||||
await this.markEventAsError(operation.eventId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate API call with delay
|
||||
await this.simulateApiCall();
|
||||
|
||||
// Simulate success (80%) or failure (20%)
|
||||
const success = Math.random() > 0.2;
|
||||
|
||||
if (success) {
|
||||
console.log(`[TestSyncManager] ✓ Operation ${operation.id} synced successfully`);
|
||||
await this.queue.remove(operation.id);
|
||||
await this.markEventAsSynced(operation.eventId);
|
||||
} else {
|
||||
console.warn(`[TestSyncManager] ✗ Operation ${operation.id} failed - will retry`);
|
||||
await this.queue.incrementRetryCount(operation.id);
|
||||
}
|
||||
}
|
||||
|
||||
async simulateApiCall() {
|
||||
// Simulate network delay (100-500ms)
|
||||
const delay = Math.floor(Math.random() * 400) + 100;
|
||||
return new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
async markEventAsSynced(eventId) {
|
||||
try {
|
||||
const event = await this.indexedDB.getEvent(eventId);
|
||||
if (event) {
|
||||
event.syncStatus = 'synced';
|
||||
await this.indexedDB.saveEvent(event);
|
||||
console.log(`[TestSyncManager] Event ${eventId} marked as synced`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TestSyncManager] Failed to mark event ${eventId} as synced:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async markEventAsError(eventId) {
|
||||
try {
|
||||
const event = await this.indexedDB.getEvent(eventId);
|
||||
if (event) {
|
||||
event.syncStatus = 'error';
|
||||
await this.indexedDB.saveEvent(event);
|
||||
console.log(`[TestSyncManager] Event ${eventId} marked as error`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TestSyncManager] Failed to mark event ${eventId} as error:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize test environment
|
||||
async function initializeTestEnvironment() {
|
||||
console.log('[Test Init] Initializing test environment...');
|
||||
|
||||
const indexedDB = new TestIndexedDBService();
|
||||
await indexedDB.initialize();
|
||||
console.log('[Test Init] IndexedDB initialized');
|
||||
|
||||
const queue = new TestOperationQueue(indexedDB);
|
||||
console.log('[Test Init] Operation queue created');
|
||||
|
||||
const eventManager = new TestEventManager(indexedDB, queue);
|
||||
console.log('[Test Init] Event manager created');
|
||||
|
||||
const syncManager = new TestSyncManager(queue, indexedDB);
|
||||
console.log('[Test Init] Sync manager created');
|
||||
|
||||
// Seed with test data if empty
|
||||
const existingEvents = await indexedDB.getAllEvents();
|
||||
if (existingEvents.length === 0) {
|
||||
console.log('[Test Init] Seeding with test data...');
|
||||
try {
|
||||
const response = await fetch('test-events.json');
|
||||
const testEvents = await response.json();
|
||||
for (const event of testEvents) {
|
||||
const savedEvent = {
|
||||
...event,
|
||||
start: new Date(event.start),
|
||||
end: new Date(event.end)
|
||||
};
|
||||
await indexedDB.saveEvent(savedEvent);
|
||||
|
||||
// If event is pending, also add to queue
|
||||
if (event.syncStatus === 'pending') {
|
||||
await queue.enqueue({
|
||||
type: 'create',
|
||||
eventId: event.id,
|
||||
data: savedEvent,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
});
|
||||
console.log(`[Test Init] Added pending event ${event.id} to queue`);
|
||||
}
|
||||
}
|
||||
console.log(`[Test Init] Seeded ${testEvents.length} test events`);
|
||||
} catch (error) {
|
||||
console.warn('[Test Init] Could not load test-events.json:', error);
|
||||
}
|
||||
} else {
|
||||
console.log(`[Test Init] IndexedDB already has ${existingEvents.length} events`);
|
||||
}
|
||||
|
||||
// Expose to window
|
||||
window.calendarDebug = {
|
||||
indexedDB,
|
||||
queue,
|
||||
eventManager,
|
||||
syncManager
|
||||
};
|
||||
|
||||
console.log('[Test Init] Test environment ready');
|
||||
return { indexedDB, queue, eventManager, syncManager };
|
||||
}
|
||||
|
||||
// Auto-initialize if script is loaded
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeTestEnvironment().catch(error => {
|
||||
console.error('[Test Init] Failed to initialize:', error);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
initializeTestEnvironment().catch(error => {
|
||||
console.error('[Test Init] Failed to initialize:', error);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue