Compare commits

..

7 Commits

@ -4,54 +4,87 @@ A WhatsApp chatbot for task management, designed to work with Evolution API in a
## 📌 Overview
This service provides a WhatsApp interface for task management within WhatsApp groups. It:
- Listens for `/tarea` commands in WhatsApp groups
- Stores tasks in a SQLite database
- Manages user permissions and group membership
- Integrates with Evolution API for WhatsApp connectivity
- Listens for `/tarea` commands in WhatsApp groups via Evolution API webhooks.
- Stores tasks, users, and groups in a SQLite database.
- Synchronizes group information periodically from the Evolution API.
- Manages user permissions and group membership (partially implemented).
- Integrates with Evolution API for WhatsApp connectivity.
## 🔐 Security Model
- **Internal Networking**: The webhook only accepts connections from Evolution API via internal Docker networking
- **Environment Variables**: Sensitive configuration is managed through environment variables
- **Group Restrictions**: Only operates within pre-approved WhatsApp groups
- **Input Validation**: Sanitizes and validates all user inputs
- **Internal Networking**: The webhook should ideally only accept connections from Evolution API via internal Docker networking (configuration dependent).
- **Environment Variables**: Sensitive configuration (API keys, URLs) is managed through environment variables.
- **Group Restrictions**: Designed to operate within pre-approved WhatsApp groups (validation logic pending implementation).
- **Input Validation**: Basic validation exists for webhook structure; needs enhancement for command arguments and user/group IDs.
## 🧱 Architecture
```mermaid
graph TD
A[Webhook Received] --> B{Valid Payload?}
B -->|No| C[Ignore]
B -->|Yes| D{From Known Group?}
D -->|Yes| E[Update User Last Seen]
D -->|No| F{Private Chat + Known User?}
F -->|No| C
F -->|Yes| E
B -->|Yes| D{Normalize IDs & Check Group Active?}
D -->|No| C[Ignore/Log]
D -->|Yes| E[Ensure User Exists in DB]
E --> G{/tarea Command?}
G -->|No| C
G -->|Yes| H{New User?}
H -->|Yes| I[Add to DB]
H -->|No| J[Process Command]
G -->|Yes| J[Process Command Logic]
J -- Success/Error --> K[Queue Response(s)]
K --> L[Process Queue & Send Response via API]
subgraph Database Interaction
E --> DB[(SQLite DB)]
J --> DB
end
subgraph Evolution API
L --> EA((Evolution API))
EA -- Webhook --> A
end
```
## ✅ Current Features
- Task creation with optional due dates
- Basic command parsing (`/tarea nueva`, `/tarea mostrar`)
- Group membership tracking
- SQLite database persistence
- Health check endpoint
- Environment validation
- Input validation for dates and commands
*(Diagram updated for planned flow)*
## ✅ Current Status (as of commit dd32a3d)
### Implemented
- Webhook server setup (`src/server.ts`) receiving Evolution API events.
- Database schema definition and initialization (`src/db.ts`).
- Group synchronization service (`src/services/group-sync.ts`) to fetch/store/cache groups.
- Webhook registration and verification with Evolution API (`src/services/webhook-manager.ts`).
- Basic `/tarea` command detection and argument parsing structure (`src/server.ts`).
- Task data models (`src/tasks/model.ts`).
- Basic task creation service stub (`src/tasks/service.ts` - needs `created_by` and assignment logic).
- Response queue structure (`src/services/response-queue.ts` - `process` method is empty).
- Unit testing setup with in-memory database (`tests/`).
- Environment variable validation (`src/server.ts`, `src/services/webhook-manager.ts`).
- Health check endpoint (`/health`).
### Incomplete / Missing Core Functionality
- **User/Group Validation:** No normalization of WhatsApp IDs or checking if messages originate from active, known groups. Users are not automatically added to the DB.
- **Core Command Logic:** Actual processing of `/tarea nueva` (parsing args, calling `TaskService`) is missing in `CommandService`. Other commands (`mostrar`, `completar`) not implemented.
- **Task Service Implementation:** `TaskService` needs updating to handle `created_by`, assignments, and potentially methods for listing/completing tasks.
- **Response Sending:** `ResponseQueue` does not yet send messages back via the Evolution API.
- **Database Migrations:** No system in place to manage schema changes.
- **Robust Error Handling:** Comprehensive error handling, logging, and transaction management need improvement, especially around API calls and DB operations.
## 🛠️ Setup
### Environment Variables
*(Ensure these are set correctly)*
```env
EVOLUTION_API_URL=http://evolution-api:3000
# Evolution API Connection
EVOLUTION_API_URL=http://evolution-api:3000 # Or your API URL
EVOLUTION_API_KEY=your-api-key
EVOLUTION_API_INSTANCE=main
WHATSAPP_COMMUNITY_ID=your-community-id
CHATBOT_PHONE_NUMBER=1234567890
WEBHOOK_URL=https://your-webhook.com
PORT=3007
NODE_ENV=production
EVOLUTION_API_INSTANCE=main # Your instance name
# WhatsApp Specific
WHATSAPP_COMMUNITY_ID=your-community-id # ID of the main community to sync groups from
CHATBOT_PHONE_NUMBER=1234567890 # Bot's normalized phone number (e.g., for assigning tasks)
# Webhook Configuration
WEBHOOK_URL=http://your-service-internal-url:3007 # URL Evolution API calls *back* to this service
PORT=3007 # Port this service listens on
# Runtime Environment
NODE_ENV=production # Or development
# Optional
# GROUP_SYNC_INTERVAL_MS=3600000 # Sync interval in ms (default: 24h)
```
### Development Setup
@ -59,31 +92,52 @@ NODE_ENV=production
# Install dependencies
bun install
# Start development server
# Copy .env.example to .env and fill in values
cp .env.example .env
# Start development server (watches for changes)
bun run dev
# Run tests
bun test
```
## 📅 Roadmap
### High Priority
- [ ] Implement ResponseQueue processing logic with retries
- [ ] Add database schema validation and migrations
- [ ] Add error recovery with transaction rollback
- [ ] Implement group sync delta updates
### Medium Priority
- [ ] Add task assignment and ownership
- [ ] Implement user permissions system
- [ ] Add rate limiting for API calls
- [ ] Create task history tracking
### Low Priority
- [ ] Add task reminders system
- [ ] Implement multi-language support
- [ ] Create analytics dashboard
- [ ] Add user-friendly task list UI
## 📅 Roadmap & Priorities (Plan)
### Phase 1: User & Group Foundation (Highest Priority)
- [ ] **Create WhatsApp ID Normalization Utility:** (`src/utils/whatsapp.ts`) Handle different ID formats.
- [ ] **Implement `ensureUserExists`:** (`src/db.ts`) Add users to DB on first interaction.
- [ ] **Implement `isGroupActive` Check:** (`src/services/group-sync.ts`, `src/server.ts`) Validate incoming messages are from known, active groups.
- [ ] **Integrate Validation in Server:** (`src/server.ts`) Use normalization and validation before processing commands.
### Phase 2: Implement `/tarea nueva` Command (High Priority)
- [ ] **Update `TaskService.createTask`:** (`src/tasks/service.ts`) Handle `created_by` and assignments (including adding assigned users via `ensureUserExists`).
- [ ] **Implement `/tarea nueva` Logic:** (`src/services/command.ts`) Parse description, due date, mentions; call `TaskService`; generate response messages.
### Phase 3: Implement Response Sending (High Priority)
- [ ] **Implement `ResponseQueue.process`:** (`src/services/response-queue.ts`) Send queued messages via Evolution API's send endpoint.
- [ ] **Trigger Queue Processing:** (`src/server.ts`) Call `ResponseQueue.process()` after command handling.
### Phase 4: Further Commands & Refinements (Medium Priority)
- [ ] Implement `/tarea mostrar [group|mine]` command.
- [ ] Implement `/tarea completar <task_id>` command.
- [ ] Add Database Migrations system.
- [ ] Improve Error Handling & Logging (API calls, DB transactions).
- [ ] Refine Group Sync (Delta updates).
### Phase 5: Advanced Features (Low Priority)
- [ ] Add task reminders system.
- [ ] Implement user permissions system.
- [ ] Add rate limiting.
- [ ] Create task history tracking.
## 🔑 Key Considerations & Caveats
* **WhatsApp ID Normalization:** Crucial for consistently identifying users and groups. Needs careful implementation to handle edge cases.
* **Response Latency:** Sending responses requires an API call back to Evolution. Ensure the `ResponseQueue` processing is efficient.
* **Group Sync:** The current full sync might be slow or rate-limited with many groups. Delta updates are recommended long-term.
* **Error Handling:** Failures in command processing or response sending should be logged clearly and potentially reported back to the user. Database operations should use transactions for atomicity (especially task+assignment creation).
* **State Management:** The current design is stateless. Complex interactions might require state persistence later.
* **Security:** Ensure group/user validation logic is robust.
## 🧪 Testing
### Running Tests
@ -92,17 +146,19 @@ bun test
```
### Test Coverage
- Webhook validation
- Command parsing
- Environment checks
- Basic error handling
- Input validation
- Database initialization and basic operations.
- Webhook validation (basic).
- Command parsing (basic structure).
- Environment checks.
- Basic error handling.
- **Needed:** Tests for ID normalization, `ensureUserExists`, `isGroupActive`, `CommandService` logic, `ResponseQueue` processing (mocking API), `TaskService` operations.
## 🧑‍💻 Contributing
1. Fork the repository
2. Create a feature branch
3. Add tests for new functionality
4. Submit a pull request
2. Create a feature branch (`git checkout -b feature/implement-user-validation`)
3. Add/update tests for new functionality
4. Ensure tests pass (`bun test`)
5. Submit a pull request
## 📚 Documentation
For detailed API documentation and architecture decisions, see the [docs/](docs/) directory.
For detailed API documentation and architecture decisions, see the [docs/](docs/) directory (if created).

@ -1,42 +1,115 @@
import { Database } from 'bun:sqlite';
import { normalizeWhatsAppId } from './utils/whatsapp'; // Import the utility
export const db = new Database('tasks.db');
// Function to get a database instance. Defaults to 'tasks.db'
export function getDb(filename: string = 'tasks.db'): Database {
return new Database(filename);
}
// Default export for the main application database
export const db = getDb();
// Initialize function now accepts a database instance
export function initializeDatabase(instance: Database) {
// Enable foreign key constraints
instance.exec(`PRAGMA foreign_keys = ON;`);
// Create users table first as others depend on it
// Use TEXT for timestamps to store higher precision ISO8601 format easily
instance.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- WhatsApp user ID (normalized)
first_seen TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
last_seen TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
);
`);
// 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 TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
active BOOLEAN DEFAULT TRUE
);
`);
export function initializeDatabase() {
db.exec(`
// Create tasks table
instance.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
description TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
due_date TIMESTAMP NULL,
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
due_date TEXT NULL, -- Store dates as ISO8601 strings or YYYY-MM-DD
completed BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMP NULL,
group_id TEXT NULL,
created_by TEXT NOT NULL,
FOREIGN KEY (created_by) REFERENCES users(id)
completed_at TEXT NULL,
group_id TEXT NULL, -- Normalized group ID
created_by TEXT NOT NULL, -- Normalized user 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 (
task_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
assigned_by TEXT NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
user_id TEXT NOT NULL, -- Normalized user ID
assigned_by TEXT NOT NULL, -- Normalized user ID
assigned_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
PRIMARY KEY (task_id, user_id),
FOREIGN KEY (task_id) REFERENCES tasks(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
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
);
`);
}
/**
* 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.
* Stores timestamps with millisecond precision.
*
* @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 strftime for millisecond precision timestamps
const insertStmt = instance.prepare(`
INSERT INTO users (id, first_seen, last_seen)
VALUES (?, strftime('%Y-%m-%d %H:%M:%f', 'now'), strftime('%Y-%m-%d %H:%M:%f', 'now'))
ON CONFLICT(id) DO NOTHING;
`);
const updateStmt = instance.prepare(`
UPDATE users
SET last_seen = strftime('%Y-%m-%d %H:%M:%f', 'now')
WHERE id = ?;
`);
// Run as transaction for atomicity
instance.transaction(() => {
insertStmt.run(normalizedId);
// Update last_seen even if the user was just inserted or already existed
updateStmt.run(normalizedId);
})(); // Immediately invoke the transaction
return normalizedId;
} catch (error) {
console.error(`[ensureUserExists] Database error for user ID ${normalizedId}:`, error);
return null;
}
}

@ -0,0 +1,61 @@
/**
* Normalizes a WhatsApp ID by removing the domain part (@s.whatsapp.net, @g.us)
* and any participant identifier (:12).
* Handles potential variations like participant IDs in group messages.
*
* Examples:
* - '1234567890@s.whatsapp.net' -> '1234567890'
* - '1234567890-1234567890@g.us' -> '1234567890-1234567890'
* - '1234567890:12@s.whatsapp.net' -> '1234567890' (handles participant format)
* - 'status_me@broadcast' -> 'status_me' (handles status broadcast)
*
* @param id The raw WhatsApp ID string. Can be null or undefined.
* @returns The normalized ID string, or null if the input is null/undefined or invalid after normalization.
*/
export function normalizeWhatsAppId(id: string | null | undefined): string | null {
if (!id) {
return null;
}
// Remove domain part (@s.whatsapp.net, @g.us, @broadcast etc.)
let normalized = id.split('@')[0];
// Handle potential participant format like '1234567890:12' by taking the part before ':'
normalized = normalized.split(':')[0];
// Basic validation: should contain alphanumeric characters, possibly hyphens for group IDs
// Allows simple numbers, group IDs with hyphens, and potentially status_me
if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) {
console.warn(`[normalizeWhatsAppId] Invalid characters found in WhatsApp ID after normalization: ${id} -> ${normalized}`);
// Return null for clearly invalid IDs after normalization
return null;
}
// Prevent empty strings after normalization
if (normalized.length === 0) {
console.warn(`[normalizeWhatsAppId] Empty string resulted from normalization: ${id}`);
return null;
}
return normalized;
}
/**
* Checks if a given raw JID represents a group chat.
*
* @param jid The raw JID string (e.g., '123-456@g.us').
* @returns True if the JID ends with '@g.us', false otherwise.
*/
export function isGroupId(jid: string | null | undefined): boolean {
return !!jid && jid.endsWith('@g.us');
}
/**
* Checks if a given raw JID represents a standard user chat.
*
* @param jid The raw JID string (e.g., '123456@s.whatsapp.net').
* @returns True if the JID ends with '@s.whatsapp.net', false otherwise.
*/
export function isUserJid(jid: string | null | undefined): boolean {
return !!jid && jid.endsWith('@s.whatsapp.net');
}

@ -1,32 +1,54 @@
import { db, initializeDatabase } from '../../src/db';
import { describe, test, expect, beforeEach } from 'bun:test';
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 database between tests
db.exec('DROP TABLE IF EXISTS tasks');
db.exec('DROP TABLE IF EXISTS task_assignments');
db.exec('DROP TABLE IF EXISTS users');
db.exec('DROP TABLE IF EXISTS groups');
initializeDatabase();
// 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 users');
testDb.exec('DROP TABLE IF EXISTS groups');
// Initialize schema on the test database instance
initializeDatabase(testDb);
});
describe('Table Creation', () => {
test('should create all required tables', () => {
const tables = db
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);
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_'));
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));
});
});
describe('Table Schemas', () => {
test('users table should have correct columns', () => {
const columns = db
const columns = testDb
.query("PRAGMA table_info(users)")
.all()
.map((c: any) => c.name);
@ -34,7 +56,7 @@ describe('Database', () => {
});
test('tasks table should have required columns', () => {
const columns = db
const columns = testDb
.query("PRAGMA table_info(tasks)")
.all()
.map((c: any) => c.name);
@ -43,84 +65,206 @@ describe('Database', () => {
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', () => {
db.exec(`
testDb.exec(`
INSERT INTO groups (id, community_id, name)
VALUES ('test-group', 'test-community', 'Test Group')
`);
const group = db.query("SELECT active FROM groups WHERE id = 'test-group'").get();
expect(group.active).toBe(1);
const group = testDb.query("SELECT active FROM groups WHERE id = 'test-group'").get();
expect(group.active).toBe(1); // SQLite uses 1 for TRUE
});
});
describe('Foreign Keys', () => {
test('task_assignments should reference tasks', () => {
const fkInfo = db
.query("PRAGMA foreign_key_list(task_assignments)")
.all();
expect(fkInfo.length).toBe(1);
expect(fkInfo[0].from).toBe('task_id');
expect(fkInfo[0].to).toBe('id');
expect(fkInfo[0].table).toBe('tasks');
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 users via created_by', () => {
const fkInfo = db
.query("PRAGMA foreign_key_list(tasks)")
.all();
expect(fkInfo.length).toBe(1);
expect(fkInfo[0].from).toBe('created_by');
expect(fkInfo[0].to).toBe('id');
expect(fkInfo[0].table).toBe('users');
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('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', () => {
test('should reject duplicate user IDs', () => {
// First insert should succeed
const firstInsert = db.prepare(`
INSERT INTO users (id) VALUES ('34650112233')
`).run();
expect(firstInsert.changes).toBe(1);
// Second insert with same ID should fail
expect(() => {
db.prepare(`
INSERT INTO users (id) VALUES ('34650112233')
`).run();
}).toThrow();
// Verify only one record exists
const count = db.prepare(`
SELECT COUNT(*) as count FROM users WHERE id = '34650112233'
`).get();
expect(count.count).toBe(1);
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();
});
});
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(() => {
db.exec(`
INSERT INTO users (id) VALUES ('34650112233');
INSERT INTO groups (id, community_id, name)
VALUES ('test-group', 'test-community', 'Test Group')
`);
// 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', () => {
const result = db.prepare(`
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', '34650112233', 'test-group')
`).run();
VALUES ('Test task', ?, ?)
`).run(user1, group1);
expect(result.changes).toBe(1);
});
test('should allow inserting private tasks', () => {
const result = db.prepare(`
test('should allow inserting private tasks with existing user', () => {
const result = testDb.prepare(`
INSERT INTO tasks (description, created_by)
VALUES ('Private task', '34650112233')
`).run();
VALUES ('Private task', ?)
`).run(user1);
expect(result.changes).toBe(1);
});
});

@ -0,0 +1,115 @@
import { describe, test, expect } from 'bun:test';
import { normalizeWhatsAppId, isGroupId, isUserJid } from '../../../src/utils/whatsapp';
describe('WhatsApp Utilities', () => {
describe('normalizeWhatsAppId', () => {
test('should normalize standard user JID', () => {
expect(normalizeWhatsAppId('1234567890@s.whatsapp.net')).toBe('1234567890');
});
test('should normalize group JID', () => {
expect(normalizeWhatsAppId('1234567890-1234567890@g.us')).toBe('1234567890-1234567890');
});
test('should normalize user JID with participant ID', () => {
expect(normalizeWhatsAppId('1234567890:12@s.whatsapp.net')).toBe('1234567890');
});
test('should normalize group JID with participant ID (less common but possible)', () => {
expect(normalizeWhatsAppId('1234567890-9876543210:5@g.us')).toBe('1234567890-9876543210');
});
test('should normalize status broadcast JID', () => {
expect(normalizeWhatsAppId('status_me@broadcast')).toBe('status_me');
});
test('should handle JID without domain', () => {
expect(normalizeWhatsAppId('1234567890')).toBe('1234567890');
});
test('should handle group JID without domain', () => {
expect(normalizeWhatsAppId('1234567890-9876543210')).toBe('1234567890-9876543210');
});
test('should return null for null input', () => {
expect(normalizeWhatsAppId(null)).toBeNull();
});
test('should return null for undefined input', () => {
expect(normalizeWhatsAppId(undefined)).toBeNull();
});
test('should return null for empty string input', () => {
expect(normalizeWhatsAppId('')).toBeNull();
});
test('should return null for invalid characters after normalization', () => {
// Example: JID containing characters not allowed after stripping domain/participant
expect(normalizeWhatsAppId('invalid!char@s.whatsapp.net')).toBeNull();
expect(normalizeWhatsAppId('123 456@s.whatsapp.net')).toBeNull(); // Contains space
});
test('should return null for JID that becomes empty after normalization', () => {
expect(normalizeWhatsAppId('@s.whatsapp.net')).toBeNull();
expect(normalizeWhatsAppId(':12@s.whatsapp.net')).toBeNull();
});
});
describe('isGroupId', () => {
test('should return true for valid group JID', () => {
expect(isGroupId('1234567890-1234567890@g.us')).toBe(true);
});
test('should return false for user JID', () => {
expect(isGroupId('1234567890@s.whatsapp.net')).toBe(false);
});
test('should return false for group JID without domain', () => {
expect(isGroupId('1234567890-1234567890')).toBe(false);
});
test('should return false for null input', () => {
expect(isGroupId(null)).toBe(false);
});
test('should return false for undefined input', () => {
expect(isGroupId(undefined)).toBe(false);
});
test('should return false for empty string input', () => {
expect(isGroupId('')).toBe(false);
});
});
describe('isUserJid', () => {
test('should return true for valid user JID', () => {
expect(isUserJid('1234567890@s.whatsapp.net')).toBe(true);
});
test('should return true for user JID with participant', () => {
expect(isUserJid('1234567890:15@s.whatsapp.net')).toBe(true);
});
test('should return false for group JID', () => {
expect(isUserJid('1234567890-1234567890@g.us')).toBe(false);
});
test('should return false for user JID without domain', () => {
expect(isUserJid('1234567890')).toBe(false);
});
test('should return false for null input', () => {
expect(isUserJid(null)).toBe(false);
});
test('should return false for undefined input', () => {
expect(isUserJid(undefined)).toBe(false);
});
test('should return false for empty string input', () => {
expect(isUserJid('')).toBe(false);
});
});
});
Loading…
Cancel
Save