···11+import { describe, expect, test, beforeEach } from "bun:test";
22+import { channelMappings, userMappings } from "./lib/db";
33+44+describe("channel mappings uniqueness", () => {
55+ beforeEach(() => {
66+ // Clean up mappings before each test
77+ const channels = channelMappings.getAll();
88+ for (const channel of channels) {
99+ channelMappings.delete(channel.slack_channel_id);
1010+ }
1111+ });
1212+1313+ test("prevents duplicate IRC channel mappings", () => {
1414+ channelMappings.create("C001", "#test");
1515+1616+ const existing = channelMappings.getByIrcChannel("#test");
1717+ expect(existing).not.toBeNull();
1818+ expect(existing?.slack_channel_id).toBe("C001");
1919+2020+ // Trying to map a different Slack channel to the same IRC channel should be prevented
2121+ const duplicate = channelMappings.getByIrcChannel("#test");
2222+ expect(duplicate).not.toBeNull();
2323+ expect(duplicate?.slack_channel_id).toBe("C001");
2424+ });
2525+2626+ test("prevents duplicate Slack channel mappings", () => {
2727+ channelMappings.create("C001", "#test");
2828+2929+ const existing = channelMappings.getBySlackChannel("C001");
3030+ expect(existing).not.toBeNull();
3131+ expect(existing?.irc_channel).toBe("#test");
3232+3333+ // The same Slack channel should keep its original mapping
3434+ channelMappings.create("C001", "#new");
3535+ const updated = channelMappings.getBySlackChannel("C001");
3636+ expect(updated?.irc_channel).toBe("#new");
3737+ });
3838+3939+ test("allows different channels to map to different IRC channels", () => {
4040+ channelMappings.create("C001", "#test1");
4141+ channelMappings.create("C002", "#test2");
4242+4343+ const mapping1 = channelMappings.getBySlackChannel("C001");
4444+ const mapping2 = channelMappings.getBySlackChannel("C002");
4545+4646+ expect(mapping1?.irc_channel).toBe("#test1");
4747+ expect(mapping2?.irc_channel).toBe("#test2");
4848+ });
4949+});
5050+5151+describe("user mappings uniqueness", () => {
5252+ beforeEach(() => {
5353+ // Clean up mappings before each test
5454+ const users = userMappings.getAll();
5555+ for (const user of users) {
5656+ userMappings.delete(user.slack_user_id);
5757+ }
5858+ });
5959+6060+ test("prevents duplicate IRC nick mappings", () => {
6161+ userMappings.create("U001", "testnick");
6262+6363+ const existing = userMappings.getByIrcNick("testnick");
6464+ expect(existing).not.toBeNull();
6565+ expect(existing?.slack_user_id).toBe("U001");
6666+6767+ // Trying to map a different Slack user to the same IRC nick should be prevented
6868+ const duplicate = userMappings.getByIrcNick("testnick");
6969+ expect(duplicate).not.toBeNull();
7070+ expect(duplicate?.slack_user_id).toBe("U001");
7171+ });
7272+7373+ test("prevents duplicate Slack user mappings", () => {
7474+ userMappings.create("U001", "testnick");
7575+7676+ const existing = userMappings.getBySlackUser("U001");
7777+ expect(existing).not.toBeNull();
7878+ expect(existing?.irc_nick).toBe("testnick");
7979+8080+ // The same Slack user should keep its original mapping
8181+ userMappings.create("U001", "newnick");
8282+ const updated = userMappings.getBySlackUser("U001");
8383+ expect(updated?.irc_nick).toBe("newnick");
8484+ });
8585+8686+ test("allows different users to map to different IRC nicks", () => {
8787+ userMappings.create("U001", "nick1");
8888+ userMappings.create("U002", "nick2");
8989+9090+ const mapping1 = userMappings.getBySlackUser("U001");
9191+ const mapping2 = userMappings.getBySlackUser("U002");
9292+9393+ expect(mapping1?.irc_nick).toBe("nick1");
9494+ expect(mapping2?.irc_nick).toBe("nick2");
9595+ });
9696+});
+44
src/commands.ts
···8686 }
87878888 try {
8989+ // Check if IRC channel is already linked
9090+ const existingIrcMapping = channelMappings.getByIrcChannel(ircChannel);
9191+ if (existingIrcMapping) {
9292+ context.respond({
9393+ response_type: "ephemeral",
9494+ text: `❌ IRC channel ${ircChannel} is already bridged to <#${existingIrcMapping.slack_channel_id}>`,
9595+ replace_original: true,
9696+ });
9797+ return;
9898+ }
9999+100100+ // Check if Slack channel is already linked
101101+ const existingSlackMapping = channelMappings.getBySlackChannel(slackChannelId);
102102+ if (existingSlackMapping) {
103103+ context.respond({
104104+ response_type: "ephemeral",
105105+ text: `❌ This channel is already bridged to ${existingSlackMapping.irc_channel}`,
106106+ replace_original: true,
107107+ });
108108+ return;
109109+ }
110110+89111 channelMappings.create(slackChannelId, ircChannel);
90112 ircClient.join(ircChannel);
91113···280302 }
281303282304 try {
305305+ // Check if IRC nick is already linked
306306+ const existingIrcMapping = userMappings.getByIrcNick(ircNick);
307307+ if (existingIrcMapping) {
308308+ context.respond({
309309+ response_type: "ephemeral",
310310+ text: `❌ IRC nick *${ircNick}* is already linked to <@${existingIrcMapping.slack_user_id}>`,
311311+ replace_original: true,
312312+ });
313313+ return;
314314+ }
315315+316316+ // Check if Slack user is already linked
317317+ const existingSlackMapping = userMappings.getBySlackUser(slackUserId);
318318+ if (existingSlackMapping) {
319319+ context.respond({
320320+ response_type: "ephemeral",
321321+ text: `❌ You are already linked to IRC nick *${existingSlackMapping.irc_nick}*`,
322322+ replace_original: true,
323323+ });
324324+ return;
325325+ }
326326+283327 userMappings.create(slackUserId, ircNick);
284328 console.log(`Created user mapping: ${slackUserId} -> ${ircNick}`);
285329
+108-2
src/lib/db.ts
···66 CREATE TABLE IF NOT EXISTS channel_mappings (
77 id INTEGER PRIMARY KEY AUTOINCREMENT,
88 slack_channel_id TEXT NOT NULL UNIQUE,
99- irc_channel TEXT NOT NULL,
99+ irc_channel TEXT NOT NULL UNIQUE,
1010 created_at INTEGER DEFAULT (strftime('%s', 'now'))
1111 )
1212`);
···1515 CREATE TABLE IF NOT EXISTS user_mappings (
1616 id INTEGER PRIMARY KEY AUTOINCREMENT,
1717 slack_user_id TEXT NOT NULL UNIQUE,
1818- irc_nick TEXT NOT NULL,
1818+ irc_nick TEXT NOT NULL UNIQUE,
1919 created_at INTEGER DEFAULT (strftime('%s', 'now'))
2020 )
2121`);
2222+2323+// Migration: Add unique constraints if they don't exist
2424+// SQLite doesn't support ALTER TABLE to add constraints, so we need to recreate the table
2525+function migrateSchema() {
2626+ // Check if irc_channel has unique constraint by examining table schema
2727+ const channelSchema = db
2828+ .query("SELECT sql FROM sqlite_master WHERE type='table' AND name='channel_mappings'")
2929+ .get() as { sql: string } | null;
3030+3131+ const hasIrcChannelUnique = channelSchema?.sql?.includes("irc_channel TEXT NOT NULL UNIQUE") ?? false;
3232+3333+ if (!hasIrcChannelUnique && channelSchema) {
3434+ // Check if table has any data with duplicate irc_channel values
3535+ const duplicates = db.query(
3636+ "SELECT irc_channel, COUNT(*) as count FROM channel_mappings GROUP BY irc_channel HAVING count > 1",
3737+ ).all();
3838+3939+ if (duplicates.length > 0) {
4040+ console.warn(
4141+ "Warning: Found duplicate IRC channel mappings. Keeping only the most recent mapping for each IRC channel.",
4242+ );
4343+ for (const dup of duplicates as { irc_channel: string }[]) {
4444+ // Delete all but the most recent mapping for this IRC channel
4545+ db.run(
4646+ `DELETE FROM channel_mappings
4747+ WHERE irc_channel = ?
4848+ AND id NOT IN (
4949+ SELECT id FROM channel_mappings
5050+ WHERE irc_channel = ?
5151+ ORDER BY created_at DESC
5252+ LIMIT 1
5353+ )`,
5454+ [dup.irc_channel, dup.irc_channel],
5555+ );
5656+ }
5757+ }
5858+5959+ // Recreate the table with unique constraint
6060+ db.run(`
6161+ CREATE TABLE channel_mappings_new (
6262+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6363+ slack_channel_id TEXT NOT NULL UNIQUE,
6464+ irc_channel TEXT NOT NULL UNIQUE,
6565+ created_at INTEGER DEFAULT (strftime('%s', 'now'))
6666+ )
6767+ `);
6868+6969+ db.run(
7070+ "INSERT INTO channel_mappings_new SELECT * FROM channel_mappings",
7171+ );
7272+ db.run("DROP TABLE channel_mappings");
7373+ db.run("ALTER TABLE channel_mappings_new RENAME TO channel_mappings");
7474+ console.log("Migrated channel_mappings table to add unique constraint on irc_channel");
7575+ }
7676+7777+ // Check if irc_nick has unique constraint by examining table schema
7878+ const userSchema = db
7979+ .query("SELECT sql FROM sqlite_master WHERE type='table' AND name='user_mappings'")
8080+ .get() as { sql: string } | null;
8181+8282+ const hasIrcNickUnique = userSchema?.sql?.includes("irc_nick TEXT NOT NULL UNIQUE") ?? false;
8383+8484+ if (!hasIrcNickUnique && userSchema) {
8585+ // Check if table has any data with duplicate irc_nick values
8686+ const duplicates = db.query(
8787+ "SELECT irc_nick, COUNT(*) as count FROM user_mappings GROUP BY irc_nick HAVING count > 1",
8888+ ).all();
8989+9090+ if (duplicates.length > 0) {
9191+ console.warn(
9292+ "Warning: Found duplicate IRC nick mappings. Keeping only the most recent mapping for each IRC nick.",
9393+ );
9494+ for (const dup of duplicates as { irc_nick: string }[]) {
9595+ // Delete all but the most recent mapping for this IRC nick
9696+ db.run(
9797+ `DELETE FROM user_mappings
9898+ WHERE irc_nick = ?
9999+ AND id NOT IN (
100100+ SELECT id FROM user_mappings
101101+ WHERE irc_nick = ?
102102+ ORDER BY created_at DESC
103103+ LIMIT 1
104104+ )`,
105105+ [dup.irc_nick, dup.irc_nick],
106106+ );
107107+ }
108108+ }
109109+110110+ // Recreate the table with unique constraint
111111+ db.run(`
112112+ CREATE TABLE user_mappings_new (
113113+ id INTEGER PRIMARY KEY AUTOINCREMENT,
114114+ slack_user_id TEXT NOT NULL UNIQUE,
115115+ irc_nick TEXT NOT NULL UNIQUE,
116116+ created_at INTEGER DEFAULT (strftime('%s', 'now'))
117117+ )
118118+ `);
119119+120120+ db.run("INSERT INTO user_mappings_new SELECT * FROM user_mappings");
121121+ db.run("DROP TABLE user_mappings");
122122+ db.run("ALTER TABLE user_mappings_new RENAME TO user_mappings");
123123+ console.log("Migrated user_mappings table to add unique constraint on irc_nick");
124124+ }
125125+}
126126+127127+migrateSchema();
2212823129db.run(`
24130 CREATE TABLE IF NOT EXISTS thread_timestamps (