Files
nanoclaw/setup/register.test.ts
glifocat 07dc8c977c test: cover multi-channel main and cross-channel name propagation
Replaces single-channel tests with multi-channel scenarios:
- each channel can have its own main with admin context
- non-main groups across channels get global template
- custom name propagates to all channels and groups
- user-modified CLAUDE.md preserved on re-registration
- missing templates handled gracefully

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:39:21 +01:00

430 lines
12 KiB
TypeScript

import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
/**
* Tests for the register step.
*
* Verifies: parameterized SQL (no injection), file templating,
* apostrophe in names, .env updates, CLAUDE.md template copy.
*/
function createTestDb(): Database.Database {
const db = new Database(':memory:');
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0
)`);
return db;
}
describe('parameterized SQL registration', () => {
let db: Database.Database;
beforeEach(() => {
db = createTestDb();
});
it('registers a group with parameterized query', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(
'123@g.us',
'Test Group',
'test-group',
'@Andy',
'2024-01-01T00:00:00.000Z',
1,
);
const row = db
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
.get('123@g.us') as {
jid: string;
name: string;
folder: string;
trigger_pattern: string;
requires_trigger: number;
};
expect(row.jid).toBe('123@g.us');
expect(row.name).toBe('Test Group');
expect(row.folder).toBe('test-group');
expect(row.trigger_pattern).toBe('@Andy');
expect(row.requires_trigger).toBe(1);
});
it('handles apostrophes in group names safely', () => {
const name = "O'Brien's Group";
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(
'456@g.us',
name,
'obriens-group',
'@Andy',
'2024-01-01T00:00:00.000Z',
0,
);
const row = db
.prepare('SELECT name FROM registered_groups WHERE jid = ?')
.get('456@g.us') as {
name: string;
};
expect(row.name).toBe(name);
});
it('prevents SQL injection in JID field', () => {
const maliciousJid = "'; DROP TABLE registered_groups; --";
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
// Table should still exist and have the row
const count = db
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as {
count: number;
};
expect(count.count).toBe(1);
const row = db.prepare('SELECT jid FROM registered_groups').get() as {
jid: string;
};
expect(row.jid).toBe(maliciousJid);
});
it('handles requiresTrigger=false', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(
'789@s.whatsapp.net',
'Personal',
'main',
'@Andy',
'2024-01-01T00:00:00.000Z',
0,
);
const row = db
.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
.get('789@s.whatsapp.net') as { requires_trigger: number };
expect(row.requires_trigger).toBe(0);
});
it('stores is_main flag', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
).run(
'789@s.whatsapp.net',
'Personal',
'whatsapp_main',
'@Andy',
'2024-01-01T00:00:00.000Z',
0,
1,
);
const row = db
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
.get('789@s.whatsapp.net') as { is_main: number };
expect(row.is_main).toBe(1);
});
it('defaults is_main to 0', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(
'123@g.us',
'Some Group',
'whatsapp_some-group',
'@Andy',
'2024-01-01T00:00:00.000Z',
1,
);
const row = db
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
.get('123@g.us') as { is_main: number };
expect(row.is_main).toBe(0);
});
it('upserts on conflict', () => {
const stmt = db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
);
stmt.run(
'123@g.us',
'Original',
'main',
'@Andy',
'2024-01-01T00:00:00.000Z',
1,
);
stmt.run(
'123@g.us',
'Updated',
'main',
'@Bot',
'2024-02-01T00:00:00.000Z',
0,
);
const rows = db.prepare('SELECT * FROM registered_groups').all();
expect(rows).toHaveLength(1);
const row = rows[0] as {
name: string;
trigger_pattern: string;
requires_trigger: number;
};
expect(row.name).toBe('Updated');
expect(row.trigger_pattern).toBe('@Bot');
expect(row.requires_trigger).toBe(0);
});
});
describe('file templating', () => {
it('replaces assistant name in CLAUDE.md content', () => {
let content = '# Andy\n\nYou are Andy, a personal assistant.';
content = content.replace(/^# Andy$/m, '# Nova');
content = content.replace(/You are Andy/g, 'You are Nova');
expect(content).toBe('# Nova\n\nYou are Nova, a personal assistant.');
});
it('handles names with special regex characters', () => {
let content = '# Andy\n\nYou are Andy.';
const newName = 'C.L.A.U.D.E';
content = content.replace(/^# Andy$/m, `# ${newName}`);
content = content.replace(/You are Andy/g, `You are ${newName}`);
expect(content).toContain('# C.L.A.U.D.E');
expect(content).toContain('You are C.L.A.U.D.E.');
});
it('updates .env ASSISTANT_NAME line', () => {
let envContent = 'SOME_KEY=value\nASSISTANT_NAME="Andy"\nOTHER=test';
envContent = envContent.replace(
/^ASSISTANT_NAME=.*$/m,
'ASSISTANT_NAME="Nova"',
);
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
expect(envContent).toContain('SOME_KEY=value');
});
it('appends ASSISTANT_NAME to .env if not present', () => {
let envContent = 'SOME_KEY=value\n';
if (!envContent.includes('ASSISTANT_NAME=')) {
envContent += '\nASSISTANT_NAME="Nova"';
}
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
});
});
describe('CLAUDE.md template copy', () => {
let tmpDir: string;
let groupsDir: string;
// Replicates register.ts template copy + name update logic
function simulateRegister(
folder: string,
isMain: boolean,
assistantName = 'Andy',
): void {
const folderDir = path.join(groupsDir, folder);
fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true });
// Template copy (register.ts lines 119-138)
const dest = path.join(folderDir, 'CLAUDE.md');
if (!fs.existsSync(dest)) {
const templatePath = isMain
? path.join(groupsDir, 'main', 'CLAUDE.md')
: path.join(groupsDir, 'global', 'CLAUDE.md');
if (fs.existsSync(templatePath)) {
fs.copyFileSync(templatePath, dest);
}
}
// Name update across all groups (register.ts lines 140-165)
if (assistantName !== 'Andy') {
const mdFiles = fs
.readdirSync(groupsDir)
.map((d) => path.join(groupsDir, d, 'CLAUDE.md'))
.filter((f) => fs.existsSync(f));
for (const mdFile of mdFiles) {
let content = fs.readFileSync(mdFile, 'utf-8');
content = content.replace(/^# Andy$/m, `# ${assistantName}`);
content = content.replace(
/You are Andy/g,
`You are ${assistantName}`,
);
fs.writeFileSync(mdFile, content);
}
}
}
function readGroupMd(folder: string): string {
return fs.readFileSync(
path.join(groupsDir, folder, 'CLAUDE.md'),
'utf-8',
);
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-'));
groupsDir = path.join(tmpDir, 'groups');
fs.mkdirSync(path.join(groupsDir, 'main'), { recursive: true });
fs.mkdirSync(path.join(groupsDir, 'global'), { recursive: true });
fs.writeFileSync(
path.join(groupsDir, 'main', 'CLAUDE.md'),
'# Andy\n\nYou are Andy, a personal assistant.\n\n## Admin Context\n\nThis is the **main channel**.',
);
fs.writeFileSync(
path.join(groupsDir, 'global', 'CLAUDE.md'),
'# Andy\n\nYou are Andy, a personal assistant.',
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('copies global template for non-main group', () => {
simulateRegister('telegram_dev-team', false);
const content = readGroupMd('telegram_dev-team');
expect(content).toContain('You are Andy');
expect(content).not.toContain('Admin Context');
});
it('copies main template for main group', () => {
simulateRegister('whatsapp_main', true);
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
});
it('each channel can have its own main with admin context', () => {
simulateRegister('whatsapp_main', true);
simulateRegister('telegram_main', true);
simulateRegister('slack_main', true);
simulateRegister('discord_main', true);
for (const folder of [
'whatsapp_main',
'telegram_main',
'slack_main',
'discord_main',
]) {
const content = readGroupMd(folder);
expect(content).toContain('Admin Context');
expect(content).toContain('You are Andy');
}
});
it('non-main groups across channels get global template', () => {
simulateRegister('whatsapp_main', true);
simulateRegister('telegram_friends', false);
simulateRegister('slack_engineering', false);
simulateRegister('discord_general', false);
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
for (const folder of [
'telegram_friends',
'slack_engineering',
'discord_general',
]) {
const content = readGroupMd(folder);
expect(content).toContain('You are Andy');
expect(content).not.toContain('Admin Context');
}
});
it('custom name propagates to all channels and groups', () => {
// Register multiple channels, last one sets custom name
simulateRegister('whatsapp_main', true);
simulateRegister('telegram_main', true);
simulateRegister('slack_devs', false);
// Final registration triggers name update across all
simulateRegister('discord_main', true, 'Luna');
for (const folder of [
'main',
'global',
'whatsapp_main',
'telegram_main',
'slack_devs',
'discord_main',
]) {
const content = readGroupMd(folder);
expect(content).toContain('# Luna');
expect(content).toContain('You are Luna');
expect(content).not.toContain('Andy');
}
});
it('does not overwrite user-modified CLAUDE.md', () => {
simulateRegister('slack_main', true);
// User customizes the file
fs.writeFileSync(
path.join(groupsDir, 'slack_main', 'CLAUDE.md'),
'# Custom\n\nUser-modified content.',
);
// Re-registering same folder (e.g. re-running /add-slack)
simulateRegister('slack_main', true);
const content = readGroupMd('slack_main');
expect(content).toContain('User-modified content');
expect(content).not.toContain('Admin Context');
});
it('handles missing templates gracefully', () => {
fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md'));
fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md'));
simulateRegister('discord_general', false);
expect(
fs.existsSync(path.join(groupsDir, 'discord_general', 'CLAUDE.md')),
).toBe(false);
});
});