Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions backend/__tests__/service/discordInstallBind.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jest.mock('../../models/Pod', () => ({ findById: jest.fn(), find: jest.fn() }));
jest.mock('../../services/discordService', () => jest.fn().mockImplementation(() => ({
initialize: jest.fn().mockResolvedValue(true),
connect: jest.fn().mockResolvedValue(true),
disconnect: jest.fn().mockResolvedValue(true),
})));
jest.mock('../../services/connectorSecrets', () => ({
put: jest.fn().mockResolvedValue('secret-ref-1'),
Expand Down Expand Up @@ -328,3 +329,73 @@ describe('binding a Discord channel takes authority over the pod', () => {
expect((await bindAs('member-token', proof)).status).toBe(201);
});
});

// F161 (CodeRabbit 4475816316): "Add the bot again" on a row in error starts a
// fresh install. The new bind retires the caller's dropped row on the same pod,
// guild and channel the way Remove would, so the page doesn't show two rows.
describe('a fresh bind replaces the dropped row it reconnects', () => {
const OTHER_CHANNEL = '456789012345678901';
const seedRow = async (over = {}) => {
const row = await Integration.create({
podId: POD_ID,
type: 'discord',
createdBy: OWNER,
status: 'error',
config: { serverId: GUILD, channelId: CHANNEL, channelName: 'general' },
...over,
});
await DiscordIntegration.create({
integrationId: row._id, serverId: GUILD, serverName: 'Commonly Dev', channelId: row.config.channelId, channelName: 'general', webhookId: 'old-hook',
});
return row;
};

const bindViaInstall = async () => {
const link = await request(app).get(`/api/discord/install-link/${POD_ID}`).set('Authorization', 'Bearer owner-token');
const authorize = new URL(link.body.installUrl);
const back = new URL(authorize.searchParams.get('redirect_uri'));
const cb = await request(app)
.get(`${back.pathname}?code=the-code&state=${authorize.searchParams.get('state')}&guild_id=${GUILD}`);
const proof = new URL(cb.headers.location).searchParams.get('install');
return request(app)
.post('/api/integrations')
.set('Authorization', 'Bearer owner-token')
.send({
podId: POD_ID,
type: 'discord',
config: { serverId: GUILD, serverName: 'Commonly Dev', channelId: CHANNEL, channelName: 'general' },
install: proof,
});
};

test.each(['error', 'disconnected'])('a %s row on the same channel ends up inactive, its webhook record gone', async (status) => {
const old = await seedRow({ status });
const bind = await bindViaInstall();
expect(bind.status).toBe(201);
const retired = await Integration.findById(old._id).lean();
expect(retired.isActive).toBe(false);
expect(retired.status).toBe('disconnected');
expect(await DiscordIntegration.findOne({ integrationId: old._id })).toBeNull();
const DiscordService = require('../../services/discordService');
const retiredService = DiscordService.mock.results
.map((r, i) => ({ value: r.value, id: String(DiscordService.mock.calls[i][0]) }))
.find((r) => r.id === String(old._id));
expect(retiredService.value.disconnect).toHaveBeenCalled();
const live = await Integration.find({ podId: POD_ID, type: 'discord', isActive: true }).lean();
expect(live.map((r) => String(r._id))).toEqual([String(bind.body.integration._id)]);
});

test('rows the bind does not replace stay as they are', async () => {
const otherChannel = await seedRow({ config: { serverId: GUILD, channelId: OTHER_CHANNEL } });
const connected = await seedRow({ status: 'connected' });
const someoneElses = await seedRow({ createdBy: MEMBER });
const bind = await bindViaInstall();
expect(bind.status).toBe(201);
for (const row of [otherChannel, connected, someoneElses]) {
// eslint-disable-next-line no-await-in-loop
const after = await Integration.findById(row._id).lean();
expect(after.isActive).toBe(true);
expect(after.status).toBe(row.status);
}
});
});
2 changes: 2 additions & 0 deletions backend/__tests__/unit/routes/discord.management-auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ describe('discord management route auth', () => {

expect(res.status).toBe(200);
expect(DiscordService).toHaveBeenCalledWith('integration-1');
// F161 item 2: the route no longer claims the legacy set per guild; it is global.
expect(res.body.commands).toEqual([]);
});

it('requires auth and admin access for register-all', async () => {
Expand Down
101 changes: 101 additions & 0 deletions backend/__tests__/unit/routes/discord.relayCommands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const Integration = require('../../../models/Integration');
const Summary = require('../../../models/Summary');
const { runCommandForIntegration } = require('../../../services/discordMultiCommandService');
const discordRoutes = require('../../../routes/discord');
const { resetSummaryCooldowns } = require('../../../services/discordRelayCommandService');

const app = express();
app.use('/api/discord/interactions', express.raw({ type: '*/*' }));
Expand Down Expand Up @@ -110,6 +111,7 @@ beforeEach(() => {
jest.clearAllMocks();
mockRows.length = 0;
mockRows.push(row());
resetSummaryCooldowns();
// The pod's creator: the bind rule /tldr re-checks (F143) passes for them.
User.findById.mockResolvedValue({ _id: 'user-owner', role: 'user' });
});
Expand Down Expand Up @@ -171,6 +173,14 @@ describe('/mute and /unmute', () => {
expect(res.body.data.flags).toBe(64);
});

// F161 item 7: the core's usage outcome, in Discord's words.
test.each(['90s', '1.5h'])('/mute %s answers with usage and writes nothing', async (value) => {
const res = await command('mute', { options: [{ name: 'duration', value }] });
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
expect(res.body.data.flags).toBe(64);
expect(res.body.data.content).toContain('Usage: /mute');
});

test('/unmute clears relayMutedUntil', async () => {
await command('unmute');
expect(Integration.findByIdAndUpdate).toHaveBeenCalledWith('row-1', { $unset: { 'config.relayMutedUntil': '' } });
Expand Down Expand Up @@ -286,3 +296,94 @@ test('/help answers anywhere, ephemerally', async () => {
expect(res.body.data.content).toContain('/mode');
expect(res.body.data.content).toContain('/tldr');
});

const settle = async () => {
await new Promise((resolve) => { setImmediate(resolve); });
await new Promise((resolve) => { setImmediate(resolve); });
};

// F161 item 3: /summary and /tldr run only on a connected row (F132's relay
// rule); a settings write on a row that is not connected says so.
describe('a bound row that is not connected', () => {
test.each(['disconnected', 'error', 'pending'])('/tldr on a %s row posts nothing', async (status) => {
mockRows[0] = { ...row(), status };
Summary.findOne.mockReturnValue({ sort: () => Promise.resolve({ title: 'T', content: 'secret pod text' }) });
const res = await command('tldr');
expect(res.body.type).toBe(4);
expect(res.body.data.flags).toBe(64);
expect(res.body.data.content).not.toContain('secret pod text');
expect(res.body.data.content).toContain("isn't active");
});

test('/summary on a disconnected row runs nothing and answers at once', async () => {
mockRows[0] = { ...row({ webhookListenerEnabled: true }), status: 'disconnected' };
const res = await command('summary');
expect(res.body.type).toBe(4);
expect(res.body.data.flags).toBe(64);
expect(res.body.data.content).toContain("isn't active");
await settle();
expect(runCommandForIntegration).not.toHaveBeenCalled();
});

test.each([
['mode', [{ name: 'mode', value: 'mirror' }]],
['mute', [{ name: 'duration', value: '30m' }]],
['unmute', undefined],
])('/%s from the installer on a disconnected row writes nothing and says so', async (name, options) => {
mockRows[0] = { ...row(), status: 'disconnected' };
const res = await command(name, { options });
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
expect(res.body.data.flags).toBe(64);
expect(res.body.data.content).toContain("isn't connected right now");
});
});

// F161 item 4: each /summary is an LLM run, so one row runs it at most once a minute.
test('/summary twice inside a minute runs once; the second is told to wait, ephemerally', async () => {
mockRows[0] = row({ webhookListenerEnabled: true });
runCommandForIntegration.mockResolvedValue({ result: { success: true, content: 'ok' } });
const first = await command('summary', { actor: STRANGER });
expect(first.body.type).toBe(5);
await settle();
const second = await command('summary', { actor: INSTALLER });
expect(second.body.type).toBe(4);
expect(second.body.data.flags).toBe(64);
expect(second.body.data.content).toMatch(/less than a minute ago/);
await settle();
expect(runCommandForIntegration).toHaveBeenCalledTimes(1);
});

test('/summary runs again once the minute has passed', async () => {
mockRows[0] = row({ webhookListenerEnabled: true });
runCommandForIntegration.mockResolvedValue({ result: { success: true, content: 'ok' } });
const realNow = Date.now;
try {
await command('summary');
await settle();
Date.now = () => realNow() + 61_000;
const again = await command('summary');
expect(again.body.type).toBe(5);
await settle();
expect(runCommandForIntegration).toHaveBeenCalledTimes(2);
} finally {
Date.now = realNow;
}
});

// F161 item 9: an admin pause takes the row out of every command's reach.
describe('a row under an admin pause', () => {
beforeEach(() => { mockRows[0] = row({ adminPause: { at: new Date(), by: 'admin-1' } }); });

test('the installer cannot change its settings', async () => {
const res = await command('mode', { options: [{ name: 'mode', value: 'mirror' }] });
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
expect(res.body.data.flags).toBe(64);
});

test('/tldr posts nothing', async () => {
Summary.findOne.mockReturnValue({ sort: () => Promise.resolve({ title: 'T', content: 'secret pod text' }) });
const res = await command('tldr');
expect(res.body.data.flags).toBe(64);
expect(res.body.data.content).not.toContain('secret pod text');
});
});
20 changes: 18 additions & 2 deletions backend/__tests__/unit/routes/slack.webhook.commands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ describe('Slack /commonly command replies', () => {
['mute', 60],
['mute 15', 15],
['mute 0', 60],
['mute -5', 1],
['mute 99999', 24 * 60],
['mute soon', 60],
['mute 2h', 120],
// F161: the whole argument reaches the parse, not just its first word.
['mute 2 hours', 120],
['mute 30 min', 30],
])('%s mutes for %i minutes', async (text, minutes) => {
bound();
const before = Date.now();
Expand All @@ -86,6 +88,20 @@ describe('Slack /commonly command replies', () => {
expect(until).toBeLessThanOrEqual(Date.now() + minutes * 60000);
});

// F161: a duration the shared core cannot read is usage, not a guess.
test.each(['mute -5', 'mute soon', 'mute 90s'])('%s answers with usage and writes nothing', async (text) => {
bound();
const res = await command(text);
expect(res.body).toEqual({ response_type: 'ephemeral', text: 'Usage: /commonly mute 30m|2h' });
expect(Integration.updateOne).not.toHaveBeenCalled();
});

test('mode still reads only its first word', async () => {
bound();
const res = await command('mode attention please');
expect(res.body).toEqual({ response_type: 'ephemeral', text: 'Relay mode: attention.' });
});

test('unmute clears only the owner mute', async () => {
bound();
const res = await command('unmute');
Expand Down
34 changes: 34 additions & 0 deletions backend/__tests__/unit/routes/telegram.webhook.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,40 @@ describe('bridge command surface (/mode /status /mute /help)', () => {
expect(until).toBeGreaterThan(Date.now());
});

// F161 item 6: the whole argument reaches the parse, so `2 hours` is 120 minutes.
it('/mute 2 hours mutes for 120 minutes', async () => {
const before = Date.now();
const res = await post('/mute 2 hours');
expect(res.status).toBe(200);
const [, update] = Integration.findByIdAndUpdate.mock.calls[0];
const until = new Date(update.$set['config.relayMutedUntil']).getTime();
expect(until).toBeGreaterThanOrEqual(before + 120 * 60000);
expect(until).toBeLessThanOrEqual(Date.now() + 120 * 60000);
expect(telegramService.sendMessage).toHaveBeenCalledWith('bot-token', '42', expect.stringContaining('Muted for 120 min'));
});

it('/mode still reads only its first word', async () => {
await post('/mode attention please');
expect(Integration.findByIdAndUpdate).toHaveBeenCalledWith(
'integration-1',
{ $set: { 'config.relayAllAgentMessages': false } },
);
});

// F161 item 7: a duration the parse cannot read is answered with usage.
it.each(['90s', '1.5h'])('/mute %s answers with usage and writes nothing', async (arg) => {
await post(`/mute ${arg}`);
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
expect(telegramService.sendMessage).toHaveBeenCalledWith('bot-token', '42', expect.stringContaining('Usage: /mute'));
});

it('/help names the duration forms', async () => {
await post('/help');
const text = telegramService.sendMessage.mock.calls[0][2];
expect(text).toContain('30m');
expect(text).toContain('2h');
});

it('/unmute only clears the owner mute and cannot clear an admin pause', async () => {
const res = await post('/unmute');

Expand Down
Loading
Loading