feat: implement ensureUserExists, add foreign keys and tests

main
borja (aider) 2 months ago
parent 3309385348
commit fbf76036f5

@ -1,4 +1,5 @@
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
import { normalizeWhatsAppId } from './utils/whatsapp'; // Import the utility
// Function to get a database instance. Defaults to 'tasks.db' // Function to get a database instance. Defaults to 'tasks.db'
export function getDb(filename: string = 'tasks.db'): Database { export function getDb(filename: string = 'tasks.db'): Database {
@ -10,6 +11,30 @@ export const db = getDb();
// Initialize function now accepts a database instance // Initialize function now accepts a database instance
export function initializeDatabase(instance: Database) { export function initializeDatabase(instance: Database) {
// Enable foreign key constraints
instance.exec(`PRAGMA foreign_keys = ON;`);
// Create users table first as others depend on it
instance.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- WhatsApp user ID (normalized)
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Create groups table
instance.exec(`
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY, -- Group ID (normalized)
community_id TEXT NOT NULL,
name TEXT,
last_verified TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
active BOOLEAN DEFAULT TRUE
);
`);
// Create tasks table
instance.exec(` instance.exec(`
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -18,32 +43,72 @@ export function initializeDatabase(instance: Database) {
due_date TIMESTAMP NULL, due_date TIMESTAMP NULL,
completed BOOLEAN DEFAULT FALSE, completed BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMP NULL, completed_at TIMESTAMP NULL,
group_id TEXT NULL, group_id TEXT NULL, -- Normalized group ID
created_by TEXT NOT NULL, created_by TEXT NOT NULL, -- Normalized user ID
FOREIGN KEY (created_by) REFERENCES users(id) FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE SET NULL -- Optional: Link task to group
); );
`);
// Create task_assignments table
instance.exec(`
CREATE TABLE IF NOT EXISTS task_assignments ( CREATE TABLE IF NOT EXISTS task_assignments (
task_id INTEGER NOT NULL, task_id INTEGER NOT NULL,
user_id TEXT NOT NULL, user_id TEXT NOT NULL, -- Normalized user ID
assigned_by TEXT NOT NULL, assigned_by TEXT NOT NULL, -- Normalized user ID
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (task_id, user_id), PRIMARY KEY (task_id, user_id),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
); FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE CASCADE
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- WhatsApp user ID (normalized phone number without +)
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
community_id TEXT NOT NULL,
name TEXT,
last_verified TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
active BOOLEAN DEFAULT TRUE
); );
`); `);
} }
/**
* Ensures a user exists in the database based on their raw WhatsApp ID.
* If the user exists, updates their last_seen timestamp.
* If the user does not exist, creates them.
* Uses the normalizeWhatsAppId utility.
*
* @param rawUserId The raw WhatsApp ID (e.g., '12345@s.whatsapp.net').
* @param instance The database instance to use (defaults to the main db).
* @returns The normalized user ID if successful, otherwise null.
*/
export function ensureUserExists(rawUserId: string | null | undefined, instance: Database = db): string | null {
const normalizedId = normalizeWhatsAppId(rawUserId);
if (!normalizedId) {
console.error(`[ensureUserExists] Could not normalize or invalid user ID provided: ${rawUserId}`);
return null;
}
try {
// Use INSERT OR IGNORE to add the user only if they don't exist,
// then UPDATE their last_seen timestamp regardless.
// This is often more efficient than SELECT followed by INSERT/UPDATE.
const insertStmt = instance.prepare(`
INSERT INTO users (id, first_seen, last_seen)
VALUES (?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO NOTHING;
`);
const updateStmt = instance.prepare(`
UPDATE users
SET last_seen = CURRENT_TIMESTAMP
WHERE id = ?;
`);
// Run as transaction for atomicity
instance.transaction(() => {
insertStmt.run(normalizedId);
updateStmt.run(normalizedId);
})(); // Immediately invoke the transaction
return normalizedId;
} catch (error) {
console.error(`[ensureUserExists] Database error for user ID ${normalizedId}:`, error);
return null;
}
}

@ -1,13 +1,18 @@
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../src/db'; import { initializeDatabase, ensureUserExists } from '../../src/db'; // Import ensureUserExists
import { describe, test, expect, beforeEach, afterAll, beforeAll } from 'bun:test'; 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', () => { describe('Database', () => {
let testDb: Database; let testDb: Database;
// Create an in-memory database before any tests run // Create an in-memory database before any tests run
beforeAll(() => { beforeAll(() => {
testDb = new Database(':memory:'); 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 // Close the database connection after all tests have run
@ -17,8 +22,8 @@ describe('Database', () => {
beforeEach(() => { beforeEach(() => {
// Reset database schema between tests by dropping tables and re-initializing // Reset database schema between tests by dropping tables and re-initializing
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 tasks');
testDb.exec('DROP TABLE IF EXISTS task_assignments');
testDb.exec('DROP TABLE IF EXISTS users'); testDb.exec('DROP TABLE IF EXISTS users');
testDb.exec('DROP TABLE IF EXISTS groups'); testDb.exec('DROP TABLE IF EXISTS groups');
// Initialize schema on the test database instance // Initialize schema on the test database instance
@ -26,15 +31,18 @@ describe('Database', () => {
}); });
describe('Table Creation', () => { describe('Table Creation', () => {
test('should create all required tables', () => { test('should create all required tables in correct order', () => {
const tables = testDb const tables = testDb
.query("SELECT name FROM sqlite_master WHERE type='table'") .query("SELECT name FROM sqlite_master WHERE type='table'")
.all() .all()
.map((t: any) => t.name); .map((t: any) => t.name);
const expectedTables = ['groups', 'task_assignments', 'tasks', 'users']; // Order matters if foreign keys are involved during creation, though SQLite is flexible
const expectedTables = ['users', 'groups', 'tasks', 'task_assignments'];
const userTables = tables.filter(t => !t.startsWith('sqlite_')); const userTables = tables.filter(t => !t.startsWith('sqlite_'));
expect(userTables.sort()).toEqual(expectedTables.sort()); // Check if all expected tables exist, order might vary slightly depending on execution
expect(userTables).toHaveLength(expectedTables.length);
expectedTables.forEach(table => expect(userTables).toContain(table));
}); });
}); });
@ -57,6 +65,14 @@ describe('Database', () => {
expect(columns).toContain('group_id'); expect(columns).toContain('group_id');
expect(columns).toContain('created_by'); 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', () => { test('groups table should have active flag default to true', () => {
testDb.exec(` testDb.exec(`
@ -69,73 +85,172 @@ describe('Database', () => {
}); });
describe('Foreign Keys', () => { describe('Foreign Keys', () => {
test('task_assignments should reference tasks', () => { test('tasks should reference users via created_by', () => {
const fkInfo = testDb const fkInfo = testDb.query("PRAGMA foreign_key_list(tasks)").all();
.query("PRAGMA foreign_key_list(task_assignments)") const createdByFk = fkInfo.find(fk => fk.from === 'created_by');
.all(); expect(createdByFk).toBeDefined();
expect(fkInfo.length).toBe(1); expect(createdByFk.table).toBe('users');
expect(fkInfo[0].from).toBe('task_id'); expect(createdByFk.to).toBe('id');
expect(fkInfo[0].to).toBe('id');
expect(fkInfo[0].table).toBe('tasks');
}); });
test('tasks should reference users via created_by', () => { test('tasks should reference groups via group_id', () => {
const fkInfo = testDb const fkInfo = testDb.query("PRAGMA foreign_key_list(tasks)").all();
.query("PRAGMA foreign_key_list(tasks)") const groupIdFk = fkInfo.find(fk => fk.from === 'group_id');
.all(); expect(groupIdFk).toBeDefined();
expect(fkInfo.length).toBe(1); expect(groupIdFk.table).toBe('groups');
expect(fkInfo[0].from).toBe('created_by'); expect(groupIdFk.to).toBe('id');
expect(fkInfo[0].to).toBe('id'); expect(groupIdFk.on_delete).toBe('SET NULL');
expect(fkInfo[0].table).toBe('users'); });
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('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('User Operations', () => { describe('ensureUserExists', () => {
test('should reject duplicate user IDs', () => { const rawUserId = '1234567890@s.whatsapp.net';
// First insert should succeed const normalizedId = '1234567890';
const firstInsert = testDb.prepare(`
INSERT INTO users (id) VALUES ('34650112233') test('should create a new user if they do not exist', () => {
`).run(); const resultId = ensureUserExists(rawUserId, testDb);
expect(firstInsert.changes).toBe(1); expect(resultId).toBe(normalizedId);
// Second insert with same ID should fail due to PRIMARY KEY constraint const user = testDb.query("SELECT * FROM users WHERE id = ?").get(normalizedId);
expect(() => { expect(user).toBeDefined();
testDb.prepare(` expect(user.id).toBe(normalizedId);
INSERT INTO users (id) VALUES ('34650112233') expect(user.first_seen).toBeDefined();
`).run(); expect(user.last_seen).toBe(user.first_seen); // Initially they are the same
}).toThrow(); // Bun's SQLite driver throws an error on constraint violation });
// Verify only one record exists test('should update last_seen for an existing user', async () => {
const countResult = testDb.prepare(` // First call creates the user
SELECT COUNT(*) as count FROM users WHERE id = '34650112233' ensureUserExists(rawUserId, testDb);
`).get(); const initialUser = testDb.query("SELECT * FROM users WHERE id = ?").get(normalizedId);
expect(countResult.count).toBe(1); const initialLastSeen = initialUser.last_seen;
// Wait a bit to ensure timestamp changes
await sleep(50);
// 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);
expect(updatedUser.last_seen).toBeDefined();
expect(updatedUser.last_seen).not.toBe(initialLastSeen); // last_seen should be updated
expect(updatedUser.first_seen).toBe(initialUser.first_seen); // first_seen should NOT be updated
});
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', () => {
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();
}); });
}); });
describe('Data Operations', () => { // 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(() => { beforeEach(() => {
// Seed necessary data for these tests into the test database // Ensure users and groups exist before task operations
ensureUserExists(`${user1}@s.whatsapp.net`, testDb);
testDb.exec(` testDb.exec(`
INSERT INTO users (id) VALUES ('34650112233'); INSERT OR IGNORE INTO groups (id, community_id, name)
INSERT INTO groups (id, community_id, name) VALUES (?, 'test-community', 'Test Group')
VALUES ('test-group', 'test-community', 'Test Group') `, [group1]);
`);
}); });
test('should allow inserting group tasks', () => { test('should allow inserting group tasks with existing user and group', () => {
const result = testDb.prepare(` const result = testDb.prepare(`
INSERT INTO tasks (description, created_by, group_id) INSERT INTO tasks (description, created_by, group_id)
VALUES ('Test task', '34650112233', 'test-group') VALUES ('Test task', ?, ?)
`).run(); `).run(user1, group1);
expect(result.changes).toBe(1); expect(result.changes).toBe(1);
}); });
test('should allow inserting private tasks', () => { test('should allow inserting private tasks with existing user', () => {
const result = testDb.prepare(` const result = testDb.prepare(`
INSERT INTO tasks (description, created_by) INSERT INTO tasks (description, created_by)
VALUES ('Private task', '34650112233') VALUES ('Private task', ?)
`).run(); `).run(user1);
expect(result.changes).toBe(1); expect(result.changes).toBe(1);
}); });
}); });

Loading…
Cancel
Save