diff --git a/README.md b/README.md index 386b3b4..f2a933a 100644 --- a/README.md +++ b/README.md @@ -422,6 +422,37 @@ slack-cli scheduled list --format simple slack-cli scheduled cancel -c general --id Q1298393284 ``` +### Drafts + +Drafts are stored locally in `~/.slack-cli/drafts.json` (Slack does not expose a public API for drafts). + +```bash +# Save a draft for a channel +slack-cli draft save -c general -m "Draft message" + +# Save a draft for a DM +slack-cli draft save --user alice -m "Draft DM" + +# Save a draft as a thread reply +slack-cli draft save -c general -m "Reply draft" -t 1234567890.123456 + +# List drafts +slack-cli draft list +slack-cli draft list --format json + +# Show the full content of a draft +slack-cli draft show --id a1b2c3d4 + +# Send a draft (deleted after sending by default) +slack-cli draft send --id a1b2c3d4 + +# Send a draft but keep it +slack-cli draft send --id a1b2c3d4 --keep + +# Delete a draft +slack-cli draft delete --id a1b2c3d4 +``` + ### Canvases ```bash @@ -627,6 +658,33 @@ Subcommands: `list`, `cancel` | --channel | -c | Channel name or ID (required) | | --id | | Scheduled message ID (required) | +### draft command + +Subcommands: `save`, `list`, `show`, `send`, `delete` + +#### draft save + +| Option | Short | Description | +| --------- | ----- | ------------------------------------------------- | +| --channel | -c | Target channel name or ID | +| --user | | Target user for DM (exclusive with --channel) | +| --message | -m | Message content (required) | +| --thread | -t | Thread timestamp to reply to | + +#### draft list + +| Option | Short | Description | +| -------- | ----- | --------------------------------------------------- | +| --format | | Output format: table, simple, json (default: table) | + +#### draft show / send / delete + +| Option | Short | Description | +| --------- | ----- | --------------------------------------------- | +| --id | | Draft ID (required) | +| --keep | | (send only) Keep the draft after sending | +| --profile | | (send only) Use specific workspace profile | + ### invite command | Option | Short | Description | diff --git a/package-lock.json b/package-lock.json index f96bae9..a68a022 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mimo-3/slack-cli", - "version": "0.22.0", + "version": "0.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mimo-3/slack-cli", - "version": "0.22.0", + "version": "0.23.0", "license": "MIT", "dependencies": { "@slack/web-api": "7.15.0", diff --git a/package.json b/package.json index 50215a3..62a2928 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mimo-3/slack-cli", - "version": "0.22.1", + "version": "0.23.0", "description": "A command-line tool for sending messages to Slack", "main": "dist/index.js", "bin": { diff --git a/src/commands/draft.ts b/src/commands/draft.ts new file mode 100644 index 0000000..e291b7d --- /dev/null +++ b/src/commands/draft.ts @@ -0,0 +1,190 @@ +import chalk from 'chalk'; +import { Command } from 'commander'; +import { renderByFormat, withSlackClient } from '../utils/command-support'; +import { wrapCommand } from '../utils/command-wrapper'; +import { type Draft, DraftStore } from '../utils/draft-store'; +import { ValidationError } from '../utils/errors'; +import { sanitizeTerminalData, sanitizeTerminalText } from '../utils/terminal-sanitizer'; +import { createValidationHook, optionValidators } from '../utils/validators'; + +interface DraftSaveOptions { + channel?: string; + user?: string; + message?: string; + thread?: string; +} + +interface DraftListOptions { + format?: string; +} + +interface DraftIdOptions { + id: string; +} + +interface DraftSendOptions extends DraftIdOptions { + keep?: boolean; + profile?: string; +} + +function formatTarget(draft: Draft): string { + return draft.user ? `@${draft.user}` : `#${draft.channel}`; +} + +function renderTable(drafts: Draft[]) { + const rows = drafts.map((draft) => ({ + id: sanitizeTerminalText(draft.id), + target: sanitizeTerminalText(formatTarget(draft)), + created_at: draft.createdAt, + message: sanitizeTerminalText( + draft.message.length > 60 ? `${draft.message.slice(0, 60)}...` : draft.message + ), + })); + + console.table(sanitizeTerminalData(rows)); +} + +function renderSimple(drafts: Draft[]) { + for (const draft of drafts) { + console.log( + `${sanitizeTerminalText(draft.id)} ${draft.createdAt} ${sanitizeTerminalText(formatTarget(draft))} ${sanitizeTerminalText(draft.message)}` + ); + } +} + +export function setupDraftCommand(): Command { + const draftCommand = new Command('draft').description( + 'Manage message drafts (save, list, show, send, delete)' + ); + + const saveCommand = new Command('save') + .description('Save a message as a local draft') + .option('-c, --channel ', 'Target channel name or ID') + .option('--user ', 'Target user for DM') + .option('-m, --message ', 'Message content') + .option('-t, --thread ', 'Thread timestamp to reply to') + .hook('preAction', createValidationHook([optionValidators.threadTimestamp])) + .action( + wrapCommand(async (options: DraftSaveOptions) => { + if (!options.channel && !options.user) { + throw new ValidationError('Either --channel or --user must be specified'); + } + if (options.channel && options.user) { + throw new ValidationError('Cannot specify both --channel and --user'); + } + if (!options.message) { + throw new ValidationError('--message is required'); + } + + const store = new DraftStore(); + const draft = await store.save({ + channel: options.channel, + user: options.user, + message: options.message, + thread: options.thread, + }); + + console.log(chalk.green(`✓ Draft saved (id: ${draft.id}, target: ${formatTarget(draft)})`)); + }) + ); + + const listCommand = new Command('list') + .description('List saved drafts') + .option('--format ', 'Output format: table, simple, json', 'table') + .hook('preAction', createValidationHook([optionValidators.format])) + .action( + wrapCommand(async (options: DraftListOptions) => { + const store = new DraftStore(); + const drafts = await store.list(); + + if (drafts.length === 0) { + console.log('No drafts found'); + return; + } + + renderByFormat(options, drafts, { + table: renderTable, + simple: renderSimple, + }); + }) + ); + + const showCommand = new Command('show') + .description('Show the full content of a draft') + .requiredOption('--id ', 'Draft ID') + .action( + wrapCommand(async (options: DraftIdOptions) => { + const store = new DraftStore(); + const draft = await store.get(options.id); + if (!draft) { + throw new ValidationError(`Draft not found: ${options.id}`); + } + + console.log(`id: ${sanitizeTerminalText(draft.id)}`); + console.log(`target: ${sanitizeTerminalText(formatTarget(draft))}`); + if (draft.thread) { + console.log(`thread: ${sanitizeTerminalText(draft.thread)}`); + } + console.log(`created_at: ${draft.createdAt}`); + console.log('---'); + console.log(sanitizeTerminalText(draft.message)); + }) + ); + + const sendCommand = new Command('send') + .description('Send a saved draft') + .requiredOption('--id ', 'Draft ID') + .option('--keep', 'Keep the draft after sending') + .option('--profile ', 'Use specific workspace profile') + .action( + wrapCommand(async (options: DraftSendOptions) => { + const store = new DraftStore(); + const draft = await store.get(options.id); + if (!draft) { + throw new ValidationError(`Draft not found: ${options.id}`); + } + + await withSlackClient(options, async (client) => { + let targetChannel: string; + if (draft.user) { + const userId = await client.resolveUserIdByName(draft.user); + targetChannel = await client.openDmChannel(userId); + } else { + targetChannel = draft.channel!; + } + + await client.sendMessage(targetChannel, draft.message, draft.thread); + }); + + console.log(chalk.green(`✓ Draft sent to ${formatTarget(draft)}`)); + + if (!options.keep) { + try { + await store.delete(draft.id); + console.log(chalk.gray(`Draft ${draft.id} deleted`)); + } catch { + console.log(chalk.yellow(`⚠ Message sent, but failed to delete draft ${draft.id}`)); + } + } + }) + ); + + const deleteCommand = new Command('delete') + .description('Delete a saved draft') + .requiredOption('--id ', 'Draft ID') + .action( + wrapCommand(async (options: DraftIdOptions) => { + const store = new DraftStore(); + await store.delete(options.id); + console.log(chalk.green(`✓ Draft ${options.id} deleted`)); + }) + ); + + draftCommand.addCommand(saveCommand); + draftCommand.addCommand(listCommand); + draftCommand.addCommand(showCommand); + draftCommand.addCommand(sendCommand); + draftCommand.addCommand(deleteCommand); + + return draftCommand; +} diff --git a/src/index.ts b/src/index.ts index 4ae0bb7..3069d8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { setupChannelsCommand } from './commands/channels'; import { setupConfigCommand } from './commands/config'; import { setupDeleteCommand } from './commands/delete'; import { setupDownloadCommand } from './commands/download'; +import { setupDraftCommand } from './commands/draft'; import { setupEditCommand } from './commands/edit'; import { setupHistoryCommand } from './commands/history'; import { setupInviteCommand } from './commands/invite'; @@ -70,6 +71,7 @@ export function createProgram(): Command { program.addCommand(setupReminderCommand()); program.addCommand(setupBookmarkCommand()); program.addCommand(setupCanvasCommand()); + program.addCommand(setupDraftCommand()); return program; } diff --git a/src/utils/draft-store.ts b/src/utils/draft-store.ts new file mode 100644 index 0000000..41d40e8 --- /dev/null +++ b/src/utils/draft-store.ts @@ -0,0 +1,115 @@ +import { randomBytes } from 'crypto'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { FILE_PERMISSIONS } from './constants'; +import { ValidationError } from './errors'; + +export interface Draft { + id: string; + channel?: string; + user?: string; + message: string; + thread?: string; + createdAt: string; +} + +export interface DraftInput { + channel?: string; + user?: string; + message: string; + thread?: string; +} + +export interface DraftStoreOptions { + configDir?: string; +} + +export class DraftStore { + private draftsPath: string; + private configDir: string; + + constructor(options: DraftStoreOptions = {}) { + this.configDir = options.configDir || path.join(os.homedir(), '.slack-cli'); + this.draftsPath = path.join(this.configDir, 'drafts.json'); + } + + async save(input: DraftInput): Promise { + const drafts = await this.readDrafts(); + const draft: Draft = { + ...input, + id: this.generateId(drafts), + createdAt: new Date().toISOString(), + }; + drafts.push(draft); + await this.writeDrafts(drafts); + return draft; + } + + async list(): Promise { + return await this.readDrafts(); + } + + async get(id: string): Promise { + const drafts = await this.readDrafts(); + return drafts.find((draft) => draft.id === id) ?? null; + } + + async delete(id: string): Promise { + const drafts = await this.readDrafts(); + const remaining = drafts.filter((draft) => draft.id !== id); + if (remaining.length === drafts.length) { + throw new ValidationError(`Draft not found: ${id}`); + } + await this.writeDrafts(remaining); + } + + private generateId(existing: Draft[]): string { + let id: string; + do { + id = randomBytes(4).toString('hex'); + } while (existing.some((draft) => draft.id === id)); + return id; + } + + private async readDrafts(): Promise { + try { + const data = await fs.readFile(this.draftsPath, 'utf-8'); + const parsed = JSON.parse(data); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter( + (entry): entry is Draft => + typeof entry === 'object' && + entry !== null && + typeof entry.id === 'string' && + typeof entry.message === 'string' + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + } + + private async writeDrafts(drafts: Draft[]): Promise { + await fs.mkdir(this.configDir, { recursive: true, mode: FILE_PERMISSIONS.CONFIG_DIR }); + await fs.chmod(this.configDir, FILE_PERMISSIONS.CONFIG_DIR); + + // Write to a temp file and rename so a crash never leaves a half-written drafts.json + const tempPath = `${this.draftsPath}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(tempPath, JSON.stringify(drafts, null, 2), { + encoding: 'utf-8', + mode: FILE_PERMISSIONS.CONFIG_FILE, + flag: 'wx', + }); + try { + await fs.rename(tempPath, this.draftsPath); + } catch (error) { + await fs.unlink(tempPath).catch(() => undefined); + throw error; + } + } +} diff --git a/tests/commands/draft.test.ts b/tests/commands/draft.test.ts new file mode 100644 index 0000000..77742dd --- /dev/null +++ b/tests/commands/draft.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupDraftCommand } from '../../src/commands/draft'; +import { DraftStore } from '../../src/utils/draft-store'; +import { ProfileConfigManager } from '../../src/utils/profile-config'; +import { SlackApiClient } from '../../src/utils/slack-api-client'; +import { createTestProgram, restoreMocks, setupMockConsole } from '../test-utils'; + +vi.mock('../../src/utils/slack-api-client'); +vi.mock('../../src/utils/profile-config'); +vi.mock('../../src/utils/draft-store'); + +describe('draft command', () => { + let program: ReturnType; + let mockSlackClient: SlackApiClient; + let mockConfigManager: ProfileConfigManager; + let mockDraftStore: DraftStore; + let mockConsole: ReturnType; + let tableSpy: ReturnType; + + const sampleDraft = { + id: 'draft-1', + channel: 'general', + message: 'hello world', + createdAt: '2026-07-16T00:00:00.000Z', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockConfigManager = new ProfileConfigManager(); + vi.mocked(ProfileConfigManager).mockImplementation(function () { + return mockConfigManager; + }); + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + + mockSlackClient = new SlackApiClient('test-token'); + vi.mocked(SlackApiClient).mockImplementation(function () { + return mockSlackClient; + }); + + mockDraftStore = new DraftStore(); + vi.mocked(DraftStore).mockImplementation(function () { + return mockDraftStore; + }); + + mockConsole = setupMockConsole(); + tableSpy = vi.spyOn(console, 'table').mockImplementation(() => undefined); + + program = createTestProgram(); + program.addCommand(setupDraftCommand()); + }); + + afterEach(() => { + restoreMocks(); + }); + + describe('save subcommand', () => { + it('should save a draft for a channel', async () => { + vi.mocked(mockDraftStore.save).mockResolvedValue(sampleDraft); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'draft', + 'save', + '-c', + 'general', + '-m', + 'hello world', + ]); + + expect(mockDraftStore.save).toHaveBeenCalledWith({ + channel: 'general', + message: 'hello world', + thread: undefined, + user: undefined, + }); + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('draft-1')); + }); + + it('should save a draft for a user DM', async () => { + vi.mocked(mockDraftStore.save).mockResolvedValue({ + ...sampleDraft, + channel: undefined, + user: 'alice', + }); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'draft', + 'save', + '--user', + 'alice', + '-m', + 'hello world', + ]); + + expect(mockDraftStore.save).toHaveBeenCalledWith({ + channel: undefined, + message: 'hello world', + thread: undefined, + user: 'alice', + }); + }); + + it('should fail when neither channel nor user is specified', async () => { + await program.parseAsync(['node', 'slack-cli', 'draft', 'save', '-m', 'hello']); + + expect(mockDraftStore.save).not.toHaveBeenCalled(); + expect(mockConsole.errorSpy).toHaveBeenCalled(); + }); + + it('should fail when message is missing', async () => { + await program.parseAsync(['node', 'slack-cli', 'draft', 'save', '-c', 'general']); + + expect(mockDraftStore.save).not.toHaveBeenCalled(); + expect(mockConsole.errorSpy).toHaveBeenCalled(); + }); + }); + + describe('list subcommand', () => { + it('should list drafts in table format by default', async () => { + vi.mocked(mockDraftStore.list).mockResolvedValue([sampleDraft]); + + await program.parseAsync(['node', 'slack-cli', 'draft', 'list']); + + expect(tableSpy).toHaveBeenCalled(); + }); + + it('should print a message when there are no drafts', async () => { + vi.mocked(mockDraftStore.list).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'draft', 'list']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith('No drafts found'); + expect(tableSpy).not.toHaveBeenCalled(); + }); + }); + + describe('show subcommand', () => { + it('should show the full draft content', async () => { + vi.mocked(mockDraftStore.get).mockResolvedValue(sampleDraft); + + await program.parseAsync(['node', 'slack-cli', 'draft', 'show', '--id', 'draft-1']); + + expect(mockDraftStore.get).toHaveBeenCalledWith('draft-1'); + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('hello world')); + }); + + it('should error when the draft does not exist', async () => { + vi.mocked(mockDraftStore.get).mockResolvedValue(null); + + await program.parseAsync(['node', 'slack-cli', 'draft', 'show', '--id', 'missing']); + + expect(mockConsole.errorSpy).toHaveBeenCalled(); + }); + }); + + describe('send subcommand', () => { + it('should send a channel draft and delete it on success', async () => { + vi.mocked(mockDraftStore.get).mockResolvedValue(sampleDraft); + vi.mocked(mockSlackClient.sendMessage).mockResolvedValue({ ok: true }); + + await program.parseAsync(['node', 'slack-cli', 'draft', 'send', '--id', 'draft-1']); + + expect(mockSlackClient.sendMessage).toHaveBeenCalledWith('general', 'hello world', undefined); + expect(mockDraftStore.delete).toHaveBeenCalledWith('draft-1'); + }); + + it('should send a DM draft by resolving the user', async () => { + vi.mocked(mockDraftStore.get).mockResolvedValue({ + ...sampleDraft, + channel: undefined, + user: 'alice', + }); + vi.mocked(mockSlackClient.resolveUserIdByName).mockResolvedValue('U123'); + vi.mocked(mockSlackClient.openDmChannel).mockResolvedValue('D123'); + vi.mocked(mockSlackClient.sendMessage).mockResolvedValue({ ok: true }); + + await program.parseAsync(['node', 'slack-cli', 'draft', 'send', '--id', 'draft-1']); + + expect(mockSlackClient.resolveUserIdByName).toHaveBeenCalledWith('alice'); + expect(mockSlackClient.sendMessage).toHaveBeenCalledWith('D123', 'hello world', undefined); + expect(mockDraftStore.delete).toHaveBeenCalledWith('draft-1'); + }); + + it('should keep the draft with --keep', async () => { + vi.mocked(mockDraftStore.get).mockResolvedValue(sampleDraft); + vi.mocked(mockSlackClient.sendMessage).mockResolvedValue({ ok: true }); + + await program.parseAsync(['node', 'slack-cli', 'draft', 'send', '--id', 'draft-1', '--keep']); + + expect(mockSlackClient.sendMessage).toHaveBeenCalled(); + expect(mockDraftStore.delete).not.toHaveBeenCalled(); + }); + + it('should not delete the draft when sending fails', async () => { + vi.mocked(mockDraftStore.get).mockResolvedValue(sampleDraft); + vi.mocked(mockSlackClient.sendMessage).mockRejectedValue(new Error('network error')); + + await program.parseAsync(['node', 'slack-cli', 'draft', 'send', '--id', 'draft-1']); + + expect(mockDraftStore.delete).not.toHaveBeenCalled(); + expect(mockConsole.errorSpy).toHaveBeenCalled(); + }); + + it('should error when the draft does not exist', async () => { + vi.mocked(mockDraftStore.get).mockResolvedValue(null); + + await program.parseAsync(['node', 'slack-cli', 'draft', 'send', '--id', 'missing']); + + expect(mockSlackClient.sendMessage).not.toHaveBeenCalled(); + expect(mockConsole.errorSpy).toHaveBeenCalled(); + }); + }); + + describe('delete subcommand', () => { + it('should delete a draft', async () => { + vi.mocked(mockDraftStore.delete).mockResolvedValue(undefined); + + await program.parseAsync(['node', 'slack-cli', 'draft', 'delete', '--id', 'draft-1']); + + expect(mockDraftStore.delete).toHaveBeenCalledWith('draft-1'); + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('draft-1')); + }); + }); +}); diff --git a/tests/utils/draft-store.test.ts b/tests/utils/draft-store.test.ts new file mode 100644 index 0000000..ad88261 --- /dev/null +++ b/tests/utils/draft-store.test.ts @@ -0,0 +1,143 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DraftStore } from '../../src/utils/draft-store'; + +describe('DraftStore', () => { + let tmpDir: string; + let store: DraftStore; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'slack-cli-draft-test-')); + store = new DraftStore({ configDir: tmpDir }); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + describe('save', () => { + it('should save a draft and assign an id and createdAt', async () => { + const draft = await store.save({ channel: 'general', message: 'hello' }); + + expect(draft.id).toBeTruthy(); + expect(draft.channel).toBe('general'); + expect(draft.message).toBe('hello'); + expect(draft.createdAt).toBeTruthy(); + }); + + it('should assign unique ids to each draft', async () => { + const first = await store.save({ channel: 'general', message: 'one' }); + const second = await store.save({ channel: 'general', message: 'two' }); + + expect(first.id).not.toBe(second.id); + }); + + it('should persist drafts to disk', async () => { + await store.save({ channel: 'general', message: 'hello' }); + + const anotherStore = new DraftStore({ configDir: tmpDir }); + const drafts = await anotherStore.list(); + + expect(drafts).toHaveLength(1); + expect(drafts[0].message).toBe('hello'); + }); + + it('should save a draft targeting a user DM', async () => { + const draft = await store.save({ user: 'alice', message: 'hi alice' }); + + expect(draft.user).toBe('alice'); + expect(draft.channel).toBeUndefined(); + }); + + it('should save a draft with thread timestamp', async () => { + const draft = await store.save({ + channel: 'general', + message: 'reply', + thread: '1234567890.123456', + }); + + expect(draft.thread).toBe('1234567890.123456'); + }); + }); + + describe('file safety', () => { + it('should write drafts.json with owner-only permissions', async () => { + await store.save({ channel: 'general', message: 'hello' }); + + const stat = await fs.stat(path.join(tmpDir, 'drafts.json')); + expect(stat.mode & 0o777).toBe(0o600); + }); + + it('should skip malformed entries in drafts.json', async () => { + await fs.writeFile( + path.join(tmpDir, 'drafts.json'), + JSON.stringify([{ id: 'ok', message: 'valid' }, { broken: true }, 'garbage']) + ); + + const drafts = await store.list(); + + expect(drafts).toHaveLength(1); + expect(drafts[0].id).toBe('ok'); + }); + }); + + describe('list', () => { + it('should return empty array when no drafts exist', async () => { + const drafts = await store.list(); + + expect(drafts).toEqual([]); + }); + + it('should list all saved drafts', async () => { + await store.save({ channel: 'general', message: 'one' }); + await store.save({ channel: 'random', message: 'two' }); + + const drafts = await store.list(); + + expect(drafts).toHaveLength(2); + }); + }); + + describe('get', () => { + it('should return the draft with the given id', async () => { + const saved = await store.save({ channel: 'general', message: 'hello' }); + + const found = await store.get(saved.id); + + expect(found).toEqual(saved); + }); + + it('should return null when the draft does not exist', async () => { + const found = await store.get('missing'); + + expect(found).toBeNull(); + }); + }); + + describe('delete', () => { + it('should delete the draft with the given id', async () => { + const saved = await store.save({ channel: 'general', message: 'hello' }); + + await store.delete(saved.id); + + expect(await store.list()).toEqual([]); + }); + + it('should throw when the draft does not exist', async () => { + await expect(store.delete('missing')).rejects.toThrow('Draft not found: missing'); + }); + + it('should keep other drafts intact', async () => { + const first = await store.save({ channel: 'general', message: 'one' }); + const second = await store.save({ channel: 'random', message: 'two' }); + + await store.delete(first.id); + + const drafts = await store.list(); + expect(drafts).toHaveLength(1); + expect(drafts[0].id).toBe(second.id); + }); + }); +});