You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			359 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			359 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
| import { Database } from 'bun:sqlite';
 | |
| import { initializeDatabase, ensureUserExists } from '../../src/db'; // Import ensureUserExists
 | |
| import { describe, test, expect, beforeEach, afterAll, beforeAll } from 'bun:test';
 | |
| 
 | |
| // Helper function to introduce slight delay for timestamp checks
 | |
| const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
 | |
| 
 | |
| describe('Database', () => {
 | |
|   let testDb: Database;
 | |
| 
 | |
|   // Create an in-memory database before any tests run
 | |
|   beforeAll(() => {
 | |
|     testDb = new Database(':memory:');
 | |
|     // Enable foreign keys for the test database instance
 | |
|     testDb.exec('PRAGMA foreign_keys = ON;'); 
 | |
|   });
 | |
| 
 | |
|   // Close the database connection after all tests have run
 | |
|   afterAll(() => {
 | |
|     testDb.close();
 | |
|   });
 | |
| 
 | |
|   beforeEach(() => {
 | |
|     // Reset del esquema entre tests.
 | |
|     // Desactivar FKs para poder dropear en cualquier orden, incluyendo tablas nuevas con FKs (p.ej., calendar_tokens).
 | |
|     testDb.exec('PRAGMA foreign_keys = OFF;');
 | |
| 
 | |
|     // Tablas añadidas en migraciones posteriores (limpieza preventiva)
 | |
|     testDb.exec('DROP TABLE IF EXISTS calendar_tokens');
 | |
|     testDb.exec('DROP TABLE IF EXISTS web_sessions');
 | |
|     testDb.exec('DROP TABLE IF EXISTS web_tokens');
 | |
|     testDb.exec('DROP TABLE IF EXISTS allowed_groups');
 | |
|     testDb.exec('DROP TABLE IF EXISTS user_aliases');
 | |
|     testDb.exec('DROP TABLE IF EXISTS user_preferences');
 | |
| 
 | |
|     // Tablas base (dependientes primero)
 | |
|     testDb.exec('DROP TABLE IF EXISTS task_assignments'); // Drop dependent tables first
 | |
|     testDb.exec('DROP TABLE IF EXISTS tasks');
 | |
|     testDb.exec('DROP TABLE IF EXISTS response_queue');
 | |
|     testDb.exec('DROP TABLE IF EXISTS group_members');
 | |
|     testDb.exec('DROP TABLE IF EXISTS groups');
 | |
|     testDb.exec('DROP TABLE IF EXISTS users');
 | |
| 
 | |
|     // Reiniciar histórico de migraciones para forzar recreación íntegra
 | |
|     testDb.exec('DROP TABLE IF EXISTS schema_migrations');
 | |
| 
 | |
|     // Re-activar FKs y re-inicializar el esquema
 | |
|     testDb.exec('PRAGMA foreign_keys = ON;');
 | |
|     initializeDatabase(testDb);
 | |
|   });
 | |
| 
 | |
|   describe('Table Creation', () => {
 | |
|     test('should create all required tables in correct order', () => {
 | |
|       const tables = testDb
 | |
|         .query("SELECT name FROM sqlite_master WHERE type='table'")
 | |
|         .all()
 | |
|         .map((t: any) => t.name);
 | |
|       
 | |
|       // Order matters if foreign keys are involved during creation, though SQLite is flexible
 | |
|       const expectedTables = ['users', 'groups', 'group_members', 'tasks', 'task_assignments', 'response_queue']; 
 | |
|       // Filtrar tablas internas y de control de migraciones
 | |
|       const userTables = tables.filter(t => !t.startsWith('sqlite_') && t !== 'schema_migrations');
 | |
|       // Check if all expected tables exist (pueden existir tablas adicionales válidas como user_preferences)
 | |
|       expectedTables.forEach(table => expect(userTables).toContain(table));
 | |
|     });
 | |
|   });
 | |
| 
 | |
|   describe('Table Schemas', () => {
 | |
|     test('users table should have correct columns', () => {
 | |
|       const columns = testDb
 | |
|         .query("PRAGMA table_info(users)")
 | |
|         .all()
 | |
|         .map((c: any) => c.name);
 | |
|       expect(columns).toEqual(['id', 'first_seen', 'last_seen']);
 | |
|     });
 | |
| 
 | |
|     test('tasks table should have required columns', () => {
 | |
|       const columns = testDb
 | |
|         .query("PRAGMA table_info(tasks)")
 | |
|         .all()
 | |
|         .map((c: any) => c.name);
 | |
|       expect(columns).toContain('description');
 | |
|       expect(columns).toContain('due_date');
 | |
|       expect(columns).toContain('group_id');
 | |
|       expect(columns).toContain('created_by');
 | |
|     });
 | |
|     
 | |
|     test('task_assignments table should have correct columns', () => {
 | |
|       const columns = testDb
 | |
|         .query("PRAGMA table_info(task_assignments)")
 | |
|         .all()
 | |
|         .map((c: any) => c.name);
 | |
|       expect(columns).toEqual(['task_id', 'user_id', 'assigned_by', 'assigned_at']);
 | |
|     });
 | |
| 
 | |
|     test('groups table should have active flag default to true', () => {
 | |
|       testDb.exec(`
 | |
|         INSERT INTO groups (id, community_id, name)
 | |
|         VALUES ('test-group', 'test-community', 'Test Group')
 | |
|       `);
 | |
|       const group = testDb.query("SELECT active FROM groups WHERE id = 'test-group'").get();
 | |
|       expect(group.active).toBe(1); // SQLite uses 1 for TRUE
 | |
|     });
 | |
| 
 | |
|     test('group_members table should have required columns', () => {
 | |
|       const columns = testDb
 | |
|         .query("PRAGMA table_info(group_members)")
 | |
|         .all()
 | |
|         .map((c: any) => c.name);
 | |
|       expect(columns).toEqual([
 | |
|         'group_id',
 | |
|         'user_id',
 | |
|         'is_admin',
 | |
|         'is_active',
 | |
|         'first_seen_at',
 | |
|         'last_seen_at',
 | |
|         'last_role_change_at'
 | |
|       ]);
 | |
|     });
 | |
| 
 | |
|     test('response_queue table should have required columns (at least base set)', () => {
 | |
|       const columns = testDb
 | |
|         .query("PRAGMA table_info(response_queue)")
 | |
|         .all()
 | |
|         .map((c: any) => c.name);
 | |
|       const expectedBase = ['id', 'recipient', 'message', 'status', 'attempts', 'last_error', 'metadata', 'created_at', 'updated_at'];
 | |
|       expectedBase.forEach(col => expect(columns).toContain(col));
 | |
|     });
 | |
|   });
 | |
| 
 | |
|   describe('Foreign Keys', () => {
 | |
|     test('tasks should reference users via created_by', () => {
 | |
|       const fkInfo = testDb.query("PRAGMA foreign_key_list(tasks)").all();
 | |
|       const createdByFk = fkInfo.find(fk => fk.from === 'created_by');
 | |
|       expect(createdByFk).toBeDefined();
 | |
|       expect(createdByFk.table).toBe('users');
 | |
|       expect(createdByFk.to).toBe('id');
 | |
|     });
 | |
| 
 | |
|     test('tasks should reference groups via group_id', () => {
 | |
|       const fkInfo = testDb.query("PRAGMA foreign_key_list(tasks)").all();
 | |
|       const groupIdFk = fkInfo.find(fk => fk.from === 'group_id');
 | |
|       expect(groupIdFk).toBeDefined();
 | |
|       expect(groupIdFk.table).toBe('groups');
 | |
|       expect(groupIdFk.to).toBe('id');
 | |
|       expect(groupIdFk.on_delete).toBe('SET NULL');
 | |
|     });
 | |
| 
 | |
|     test('task_assignments should reference tasks via task_id', () => {
 | |
|       const fkInfo = testDb.query("PRAGMA foreign_key_list(task_assignments)").all();
 | |
|       const taskIdFk = fkInfo.find(fk => fk.from === 'task_id');
 | |
|       expect(taskIdFk).toBeDefined();
 | |
|       expect(taskIdFk.table).toBe('tasks');
 | |
|       expect(taskIdFk.to).toBe('id');
 | |
|       expect(taskIdFk.on_delete).toBe('CASCADE');
 | |
|     });
 | |
|     
 | |
|     test('task_assignments should reference users via user_id', () => {
 | |
|       const fkInfo = testDb.query("PRAGMA foreign_key_list(task_assignments)").all();
 | |
|       const userIdFk = fkInfo.find(fk => fk.from === 'user_id');
 | |
|       expect(userIdFk).toBeDefined();
 | |
|       expect(userIdFk.table).toBe('users');
 | |
|       expect(userIdFk.to).toBe('id');
 | |
|       expect(userIdFk.on_delete).toBe('CASCADE');
 | |
|     });
 | |
|     
 | |
|     test('task_assignments should reference users via assigned_by', () => {
 | |
|       const fkInfo = testDb.query("PRAGMA foreign_key_list(task_assignments)").all();
 | |
|       const assignedByFk = fkInfo.find(fk => fk.from === 'assigned_by');
 | |
|       expect(assignedByFk).toBeDefined();
 | |
|       expect(assignedByFk.table).toBe('users');
 | |
|       expect(assignedByFk.to).toBe('id');
 | |
|       expect(assignedByFk.on_delete).toBe('CASCADE');
 | |
|     });
 | |
| 
 | |
|     test('group_members should reference groups and users via FKs', () => {
 | |
|       const fkInfo = testDb.query("PRAGMA foreign_key_list(group_members)").all();
 | |
|       const groupFk = fkInfo.find((fk: any) => fk.from === 'group_id');
 | |
|       const userFk = fkInfo.find((fk: any) => fk.from === 'user_id');
 | |
| 
 | |
|       expect(groupFk).toBeDefined();
 | |
|       expect(groupFk.table).toBe('groups');
 | |
|       expect(groupFk.to).toBe('id');
 | |
|       expect(groupFk.on_delete).toBe('CASCADE');
 | |
| 
 | |
|       expect(userFk).toBeDefined();
 | |
|       expect(userFk.table).toBe('users');
 | |
|       expect(userFk.to).toBe('id');
 | |
|       expect(userFk.on_delete).toBe('CASCADE');
 | |
|     });
 | |
| 
 | |
|     test('should prevent inserting group_members with non-existent FKs', () => {
 | |
|       expect(() => {
 | |
|         testDb.prepare(`
 | |
|           INSERT INTO group_members (group_id, user_id, is_admin)
 | |
|           VALUES ('nonexistent-group', 'nonexistent-user', 0)
 | |
|         `).run();
 | |
|       }).toThrow();
 | |
|     });
 | |
| 
 | |
|     test('deleting a group cascades to group_members', () => {
 | |
|       // Arrange: create user and group and membership
 | |
|       testDb.exec(`INSERT INTO users (id) VALUES ('user-x')`);
 | |
|       testDb.exec(`INSERT INTO groups (id, community_id, name) VALUES ('group-x', 'comm', 'Group X')`);
 | |
|       testDb.exec(`INSERT INTO group_members (group_id, user_id, is_admin) VALUES ('group-x', 'user-x', 0)`);
 | |
| 
 | |
|       // Ensure membership exists
 | |
|       let count = testDb.query(`SELECT COUNT(*) as c FROM group_members WHERE group_id='group-x' AND user_id='user-x'`).get() as any;
 | |
|       expect(Number(count.c)).toBe(1);
 | |
| 
 | |
|       // Act: delete group
 | |
|       testDb.exec(`DELETE FROM groups WHERE id='group-x'`);
 | |
| 
 | |
|       // Assert: membership is gone due to ON DELETE CASCADE
 | |
|       count = testDb.query(`SELECT COUNT(*) as c FROM group_members WHERE group_id='group-x' AND user_id='user-x'`).get() as any;
 | |
|       expect(Number(count.c)).toBe(0);
 | |
|     });
 | |
| 
 | |
|     test('should prevent inserting task with non-existent user', () => {
 | |
|        expect(() => {
 | |
|          testDb.prepare(`
 | |
|            INSERT INTO tasks (description, created_by) 
 | |
|            VALUES ('Task for non-existent user', 'nonexistentuser')
 | |
|          `).run();
 | |
|        }).toThrow(); // Foreign key constraint failure
 | |
|     });
 | |
|     
 | |
|     test('should prevent inserting assignment with non-existent user', () => {
 | |
|        // Need a valid user and task first
 | |
|        testDb.exec(`INSERT INTO users (id) VALUES ('user1')`);
 | |
|        const taskResult = testDb.prepare(`INSERT INTO tasks (description, created_by) VALUES ('Test Task', 'user1') RETURNING id`).get();
 | |
|        const taskId = taskResult.id;
 | |
|        
 | |
|        expect(() => {
 | |
|          testDb.prepare(`
 | |
|            INSERT INTO task_assignments (task_id, user_id, assigned_by) 
 | |
|            VALUES (?, 'nonexistentuser', 'user1')
 | |
|          `).run(taskId);
 | |
|        }).toThrow(); // Foreign key constraint failure on user_id
 | |
|        
 | |
|        expect(() => {
 | |
|          testDb.prepare(`
 | |
|            INSERT INTO task_assignments (task_id, user_id, assigned_by) 
 | |
|            VALUES (?, 'user1', 'nonexistentassigner')
 | |
|          `).run(taskId);
 | |
|        }).toThrow(); // Foreign key constraint failure on assigned_by
 | |
|     });
 | |
|   });
 | |
| 
 | |
|   describe('ensureUserExists', () => {
 | |
|     const rawUserId = '1234567890@s.whatsapp.net';
 | |
|     const normalizedId = '1234567890';
 | |
| 
 | |
|     test('should create a new user if they do not exist', () => {
 | |
|       const resultId = ensureUserExists(rawUserId, testDb);
 | |
|       expect(resultId).toBe(normalizedId);
 | |
| 
 | |
|       const user = testDb.query("SELECT * FROM users WHERE id = ?").get(normalizedId);
 | |
|       expect(user).toBeDefined();
 | |
|       expect(user.id).toBe(normalizedId);
 | |
|       expect(user.first_seen).toBeDefined();
 | |
|       expect(user.last_seen).toBe(user.first_seen); // Initially they are the same
 | |
|     });
 | |
| 
 | |
|     test('should update last_seen for an existing user', async () => {
 | |
|       // First call creates the user
 | |
|       ensureUserExists(rawUserId, testDb);
 | |
|       const initialUser = testDb.query("SELECT * FROM users WHERE id = ?").get(normalizedId);
 | |
|       const initialLastSeenStr = initialUser.last_seen;
 | |
|       const initialLastSeenMs = Date.parse(initialLastSeenStr); // Convert to milliseconds
 | |
| 
 | |
|       // Wait a bit to ensure timestamp potentially changes
 | |
|       // Increase sleep slightly to make change more likely even if DB is fast
 | |
|       await sleep(100); 
 | |
| 
 | |
|       // Second call should update last_seen
 | |
|       const resultId = ensureUserExists(rawUserId, testDb);
 | |
|       expect(resultId).toBe(normalizedId);
 | |
| 
 | |
|       const updatedUser = testDb.query("SELECT * FROM users WHERE id = ?").get(normalizedId);
 | |
|       const updatedLastSeenStr = updatedUser.last_seen;
 | |
|       const updatedLastSeenMs = Date.parse(updatedLastSeenStr); // Convert to milliseconds
 | |
| 
 | |
|       expect(updatedLastSeenStr).toBeDefined();
 | |
|       expect(updatedUser.first_seen).toBe(initialUser.first_seen); // first_seen should NOT be updated
 | |
|       
 | |
|       // Compare timestamps numerically (milliseconds since epoch)
 | |
|       // It should be greater than or equal to the initial one.
 | |
|       // Using '>' is safer if the sleep guarantees a change.
 | |
|       expect(updatedLastSeenMs).toBeGreaterThan(initialLastSeenMs); 
 | |
|       
 | |
|       // Keep the string check just in case, but the numerical one is more reliable here
 | |
|       // This might still fail occasionally if the calls land in the exact same second.
 | |
|       // expect(updatedLastSeenStr).not.toBe(initialLastSeenStr); 
 | |
|     });
 | |
| 
 | |
|     test('should return the normalized ID on success', () => {
 | |
|       const resultId = ensureUserExists('9876543210@s.whatsapp.net', testDb);
 | |
|       expect(resultId).toBe('9876543210');
 | |
|     });
 | |
| 
 | |
|     test('should return null for invalid raw user ID', () => {
 | |
|       // Suppress console.error during this specific test if needed, or just check output
 | |
|       const resultId = ensureUserExists('invalid-id!', testDb);
 | |
|       expect(resultId).toBeNull();
 | |
|       const userCount = testDb.query("SELECT COUNT(*) as count FROM users").get();
 | |
|       expect(userCount.count).toBe(0); // No user should be created
 | |
|     });
 | |
| 
 | |
|     test('should return null for null input', () => {
 | |
|       const resultId = ensureUserExists(null, testDb);
 | |
|       expect(resultId).toBeNull();
 | |
|     });
 | |
| 
 | |
|     test('should return null for undefined input', () => {
 | |
|       const resultId = ensureUserExists(undefined, testDb);
 | |
|       expect(resultId).toBeNull();
 | |
|     });
 | |
|     
 | |
|     test('should handle user ID with participant correctly', () => {
 | |
|       const resultId = ensureUserExists('1122334455:12@s.whatsapp.net', testDb);
 | |
|       expect(resultId).toBe('1122334455');
 | |
|       const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1122334455');
 | |
|       expect(user).toBeDefined();
 | |
|     });
 | |
|   });
 | |
| 
 | |
|   // Keep existing Data Operations tests, but ensure users/groups are created first
 | |
|   describe('Data Operations (with user checks)', () => {
 | |
|     const user1 = '34650112233';
 | |
|     const group1 = 'test-group-123';
 | |
| 
 | |
|     beforeEach(() => {
 | |
|       // Ensure users and groups exist before task operations
 | |
|       ensureUserExists(`${user1}@s.whatsapp.net`, testDb);
 | |
|       testDb.exec(`
 | |
|         INSERT OR IGNORE INTO groups (id, community_id, name)
 | |
|         VALUES (?, 'test-community', 'Test Group')
 | |
|       `, [group1]);
 | |
|     });
 | |
| 
 | |
|     test('should allow inserting group tasks with existing user and group', () => {
 | |
|       const result = testDb.prepare(`
 | |
|         INSERT INTO tasks (description, created_by, group_id)
 | |
|         VALUES ('Test task', ?, ?)
 | |
|       `).run(user1, group1);
 | |
|       expect(result.changes).toBe(1);
 | |
|     });
 | |
| 
 | |
|     test('should allow inserting private tasks with existing user', () => {
 | |
|       const result = testDb.prepare(`
 | |
|         INSERT INTO tasks (description, created_by)
 | |
|         VALUES ('Private task', ?)
 | |
|       `).run(user1);
 | |
|       expect(result.changes).toBe(1);
 | |
|     });
 | |
|   });
 | |
| });
 |