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
25 changes: 25 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Test

on:
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9

- uses: actions/setup-node@v4
with:
node-version: 22

- run: pnpm install --frozen-lockfile

- run: pnpm test

- run: pnpm build
148 changes: 148 additions & 0 deletions __tests__/commands/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

vi.mock("../../src/client.js", () => ({
get: vi.fn(),
post: vi.fn(),
}));

import { notificationsCommand } from "../../src/commands/notifications.js";
import { post } from "../../src/client.js";

let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe("notifications command", () => {
it("sends notification with subject and text", async () => {
vi.mocked(post).mockResolvedValue({
success: true,
message: "Email sent successfully.",
id: "email-123",
});

await notificationsCommand.parseAsync(
["--subject", "Test Subject", "--text", "Hello world"],
{ from: "user" },
);

expect(post).toHaveBeenCalledWith("/api/notifications", {
subject: "Test Subject",
text: "Hello world",
});
expect(logSpy).toHaveBeenCalledWith("Email sent successfully.");
});

it("sends notification with html body", async () => {
vi.mocked(post).mockResolvedValue({
success: true,
message: "Email sent successfully.",
id: "email-456",
});

await notificationsCommand.parseAsync(
["--subject", "Weekly Pulse", "--html", "<h1>Report</h1>"],
{ from: "user" },
);

expect(post).toHaveBeenCalledWith("/api/notifications", {
subject: "Weekly Pulse",
html: "<h1>Report</h1>",
});
});

it("passes cc and room-id options", async () => {
vi.mocked(post).mockResolvedValue({
success: true,
message: "Email sent successfully.",
id: "email-789",
});

await notificationsCommand.parseAsync(
[
"--subject",
"Update",
"--text",
"Hello",
"--cc",
"cc@example.com",
"--room-id",
"room-abc",
],
{ from: "user" },
);

expect(post).toHaveBeenCalledWith("/api/notifications", {
subject: "Update",
text: "Hello",
cc: ["cc@example.com"],
room_id: "room-abc",
});
});

it("supports multiple cc recipients", async () => {
vi.mocked(post).mockResolvedValue({
success: true,
message: "Email sent successfully.",
id: "email-multi",
});

await notificationsCommand.parseAsync(
[
"--subject",
"Update",
"--cc",
"a@example.com",
"--cc",
"b@example.com",
],
{ from: "user" },
);

expect(post).toHaveBeenCalledWith("/api/notifications", {
subject: "Update",
cc: ["a@example.com", "b@example.com"],
});
});

it("prints JSON with --json flag", async () => {
const response = {
success: true,
message: "Email sent successfully.",
id: "email-123",
};
vi.mocked(post).mockResolvedValue(response);

await notificationsCommand.parseAsync(
["--subject", "Test", "--json"],
{ from: "user" },
);

expect(logSpy).toHaveBeenCalledWith(
JSON.stringify(response, null, 2),
);
});

it("prints error on failure", async () => {
vi.mocked(post).mockRejectedValue(new Error("No email address found"));

await notificationsCommand.parseAsync(
["--subject", "Test"],
{ from: "user" },
);

expect(errorSpy).toHaveBeenCalledWith("Error: No email address found");
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
2 changes: 2 additions & 0 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { whoamiCommand } from "./commands/whoami.js";
import { artistsCommand } from "./commands/artists.js";
import { chatsCommand } from "./commands/chats.js";
import { sandboxesCommand } from "./commands/sandboxes.js";
import { notificationsCommand } from "./commands/notifications.js";
import { orgsCommand } from "./commands/orgs.js";

const program = new Command();
Expand All @@ -15,6 +16,7 @@ program
program.addCommand(whoamiCommand);
program.addCommand(artistsCommand);
program.addCommand(chatsCommand);
program.addCommand(notificationsCommand);
program.addCommand(sandboxesCommand);
program.addCommand(orgsCommand);

Expand Down
33 changes: 33 additions & 0 deletions src/commands/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Command } from "commander";
import { post } from "../client.js";
import { printJson, printError } from "../output.js";

export const notificationsCommand = new Command("notifications")
.description("Send a notification email to the authenticated account")
.requiredOption("--subject <text>", "Email subject line")
.option("--text <body>", "Plain text or Markdown body")
.option("--html <body>", "Raw HTML body (takes precedence over --text)")
.option("--cc <email>", "CC recipient (repeatable)", (val: string, prev: string[]) => prev.concat(val), [] as string[])
.option("--room-id <id>", "Room ID for chat link in footer")
.option("--json", "Output as JSON")
.action(async (opts) => {
try {
const body: Record<string, unknown> = {
subject: opts.subject,
};
if (opts.text) body.text = opts.text;
if (opts.html) body.html = opts.html;
if (opts.cc && opts.cc.length > 0) body.cc = opts.cc;
if (opts.roomId) body.room_id = opts.roomId;

const data = await post("/api/notifications", body);

if (opts.json) {
printJson(data);
} else {
console.log(data.message || "Notification sent.");
}
} catch (err) {
printError((err as Error).message);
}
});