@lightupai/polaris 0.0.43 → 0.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Community activity alerts
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
discussion:
|
|
5
|
+
types: [created]
|
|
6
|
+
discussion_comment:
|
|
7
|
+
types: [created]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
notify:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- name: Post to Slack
|
|
14
|
+
env:
|
|
15
|
+
SLACK_BOT_TOKEN: ${{ secrets.SIGNUP_SLACK_BOT_TOKEN }}
|
|
16
|
+
run: |
|
|
17
|
+
if [ "${{ github.event_name }}" = "discussion" ]; then
|
|
18
|
+
TITLE="${{ github.event.discussion.title }}"
|
|
19
|
+
AUTHOR="${{ github.event.discussion.user.login }}"
|
|
20
|
+
URL="${{ github.event.discussion.html_url }}"
|
|
21
|
+
CATEGORY="${{ github.event.discussion.category.name }}"
|
|
22
|
+
TEXT=":speech_balloon: New discussion in *${CATEGORY}*: <${URL}|${TITLE}> by ${AUTHOR}"
|
|
23
|
+
else
|
|
24
|
+
AUTHOR="${{ github.event.comment.user.login }}"
|
|
25
|
+
TITLE="${{ github.event.discussion.title }}"
|
|
26
|
+
URL="${{ github.event.comment.html_url }}"
|
|
27
|
+
TEXT=":left_speech_bubble: ${AUTHOR} commented on <${URL}|${TITLE}>"
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
curl -s -X POST https://slack.com/api/chat.postMessage \
|
|
31
|
+
-H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \
|
|
32
|
+
-H "Content-Type: application/json" \
|
|
33
|
+
-d "$(jq -n --arg channel "#alerts-mql-stream" --arg text "$TEXT" '{channel: $channel, text: $text}')"
|
package/package.json
CHANGED
package/src/web/layout.ts
CHANGED
|
@@ -69,7 +69,8 @@ export function nav(token?: string, opts?: NavOpts): string {
|
|
|
69
69
|
<a href="/" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Log out</a>
|
|
70
70
|
</div>
|
|
71
71
|
</div>`
|
|
72
|
-
: `<a href="/
|
|
72
|
+
: `<a href="https://github.com/lightup-data/polaris/discussions" class="text-sm font-medium text-gray-500 hover:text-gray-700 hidden sm:block">Community</a>
|
|
73
|
+
<a href="/login" class="text-sm font-medium text-gray-500 hover:text-gray-700 hidden sm:block">Sign in</a>
|
|
73
74
|
<a href="/signup" class="inline-flex items-center gap-2 px-3 py-1.5 sm:px-4 sm:py-2 bg-white border border-gray-300 rounded-lg shadow-sm hover:bg-gray-50 transition text-xs sm:text-sm font-medium text-gray-700">
|
|
74
75
|
<svg width="14" height="14" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 01-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/><path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 009 18z" fill="#34A853"/><path d="M3.964 10.71A5.41 5.41 0 013.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 000 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/><path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 00.957 4.958L3.964 6.29C4.672 4.163 6.656 2.58 9 3.58z" fill="#EA4335"/></svg>
|
|
75
76
|
Sign up
|
package/src/web/pages.ts
CHANGED
|
@@ -401,11 +401,15 @@ export function renderLandingPage(): string {
|
|
|
401
401
|
<div class="py-16 border-t border-gray-200 text-center">
|
|
402
402
|
<h2 class="text-2xl font-bold text-gray-900">Ready to try it?</h2>
|
|
403
403
|
<p class="mt-2 text-sm text-gray-500">Set up takes less than two minutes.</p>
|
|
404
|
-
<div class="mt-6">
|
|
404
|
+
<div class="mt-6 flex flex-col items-center gap-4">
|
|
405
405
|
<a href="/signup" class="inline-flex items-center gap-3 px-5 py-2.5 bg-white border border-gray-300 rounded-lg shadow-sm hover:bg-gray-50 transition text-sm font-medium text-gray-700">
|
|
406
406
|
<svg width="16" height="16" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 01-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/><path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 009 18z" fill="#34A853"/><path d="M3.964 10.71A5.41 5.41 0 013.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 000 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/><path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 00.957 4.958L3.964 6.29C4.672 4.163 6.656 2.58 9 3.58z" fill="#EA4335"/></svg>
|
|
407
407
|
Sign up with Google
|
|
408
408
|
</a>
|
|
409
|
+
<a href="https://github.com/lightup-data/polaris/discussions" class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-gray-500 hover:text-gray-700 transition">
|
|
410
|
+
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor"><path d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zM5 8a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm3 1a1 1 0 100-2 1 1 0 000 2z"/></svg>
|
|
411
|
+
Join the community
|
|
412
|
+
</a>
|
|
409
413
|
</div>
|
|
410
414
|
</div>
|
|
411
415
|
</div>
|
|
@@ -414,6 +418,7 @@ export function renderLandingPage(): string {
|
|
|
414
418
|
<div class="max-w-3xl mx-auto px-6 py-8 flex items-center justify-between text-sm text-gray-400">
|
|
415
419
|
<span>Polaris</span>
|
|
416
420
|
<div class="flex items-center gap-6">
|
|
421
|
+
<a href="https://github.com/lightup-data/polaris/discussions" class="hover:text-gray-600 transition">Community</a>
|
|
417
422
|
<a href="/login" class="hover:text-gray-600 transition">Sign in</a>
|
|
418
423
|
</div>
|
|
419
424
|
</div>
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:test";
|
|
2
|
+
import { createDb, createOrg, createUser, getRecentSignups, type Sql } from "../src/service/db";
|
|
3
|
+
import { resetTestData } from "./helpers";
|
|
4
|
+
|
|
5
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
|
|
6
|
+
|
|
7
|
+
let sql: Sql;
|
|
8
|
+
|
|
9
|
+
// Capture outgoing Slack API calls
|
|
10
|
+
const slackCalls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
|
11
|
+
const originalFetch = globalThis.fetch;
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
sql = await createDb(DATABASE_URL);
|
|
15
|
+
|
|
16
|
+
// Intercept fetch to capture Slack API calls
|
|
17
|
+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
18
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
19
|
+
|
|
20
|
+
if (url.startsWith("https://slack.com/api/")) {
|
|
21
|
+
const body = JSON.parse(init?.body as string);
|
|
22
|
+
slackCalls.push({ url, body });
|
|
23
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return originalFetch(input, init);
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
globalThis.fetch = originalFetch;
|
|
34
|
+
await sql.end();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
await resetTestData(sql);
|
|
39
|
+
slackCalls.length = 0;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// --- getRecentSignups ---
|
|
43
|
+
|
|
44
|
+
describe("getRecentSignups", () => {
|
|
45
|
+
test("returns users created within the time window", async () => {
|
|
46
|
+
await createOrg(sql, "org1", "Acme", "acme.com");
|
|
47
|
+
await createUser(sql, "u1", "alice@acme.com", "Alice", "org1", "user:alice");
|
|
48
|
+
await createUser(sql, "u2", "bob@acme.com", "Bob", "org1", "user:bob");
|
|
49
|
+
|
|
50
|
+
const since = new Date(Date.now() - 60 * 60 * 1000);
|
|
51
|
+
const signups = await getRecentSignups(sql, since, 10);
|
|
52
|
+
|
|
53
|
+
expect(signups).toHaveLength(2);
|
|
54
|
+
expect(signups[0].org_name).toBe("Acme");
|
|
55
|
+
expect(signups.map((s) => s.name).sort()).toEqual(["Alice", "Bob"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("excludes users created before the time window", async () => {
|
|
59
|
+
await createOrg(sql, "org1", "Acme", "acme.com");
|
|
60
|
+
await createUser(sql, "u1", "old@acme.com", "Old User", "org1", "user:old");
|
|
61
|
+
await sql`UPDATE users SET created_at = now() - interval '2 hours' WHERE id = 'u1'`;
|
|
62
|
+
|
|
63
|
+
const since = new Date(Date.now() - 60 * 60 * 1000);
|
|
64
|
+
const signups = await getRecentSignups(sql, since, 10);
|
|
65
|
+
|
|
66
|
+
expect(signups).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("respects limit parameter", async () => {
|
|
70
|
+
await createOrg(sql, "org1", "Acme", "acme.com");
|
|
71
|
+
for (let i = 0; i < 5; i++) {
|
|
72
|
+
await createUser(sql, `u${i}`, `user${i}@acme.com`, `User ${i}`, "org1", `user:user${i}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const since = new Date(Date.now() - 60 * 60 * 1000);
|
|
76
|
+
const signups = await getRecentSignups(sql, since, 3);
|
|
77
|
+
|
|
78
|
+
expect(signups).toHaveLength(3);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("returns most recent first", async () => {
|
|
82
|
+
await createOrg(sql, "org1", "Acme", "acme.com");
|
|
83
|
+
await createUser(sql, "u1", "first@acme.com", "First", "org1", "user:first");
|
|
84
|
+
await sql`UPDATE users SET created_at = now() - interval '10 seconds' WHERE id = 'u1'`;
|
|
85
|
+
await createUser(sql, "u2", "second@acme.com", "Second", "org1", "user:second");
|
|
86
|
+
|
|
87
|
+
const since = new Date(Date.now() - 60 * 60 * 1000);
|
|
88
|
+
const signups = await getRecentSignups(sql, since, 10);
|
|
89
|
+
|
|
90
|
+
expect(signups[0].name).toBe("Second");
|
|
91
|
+
expect(signups[1].name).toBe("First");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("joins org name correctly across multiple orgs", async () => {
|
|
95
|
+
await createOrg(sql, "org1", "Acme", "acme.com");
|
|
96
|
+
await createOrg(sql, "org2", "Startup", "startup.io");
|
|
97
|
+
await createUser(sql, "u1", "alice@acme.com", "Alice", "org1", "user:alice");
|
|
98
|
+
await createUser(sql, "u2", "bob@startup.io", "Bob", "org2", "user:bob");
|
|
99
|
+
|
|
100
|
+
const since = new Date(Date.now() - 60 * 60 * 1000);
|
|
101
|
+
const signups = await getRecentSignups(sql, since, 10);
|
|
102
|
+
|
|
103
|
+
const alice = signups.find((s) => s.name === "Alice");
|
|
104
|
+
const bob = signups.find((s) => s.name === "Bob");
|
|
105
|
+
expect(alice?.org_name).toBe("Acme");
|
|
106
|
+
expect(bob?.org_name).toBe("Startup");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// --- notifySignup (imported indirectly via the app module) ---
|
|
111
|
+
// We test the notification by importing and calling it directly.
|
|
112
|
+
|
|
113
|
+
// Since notifySignup is a private function in app.ts, we test the
|
|
114
|
+
// observable behavior: when the web app processes a signup, the right
|
|
115
|
+
// Slack calls are made. We simulate this by importing the notification
|
|
116
|
+
// logic as a standalone function.
|
|
117
|
+
|
|
118
|
+
// Extract the notification logic for direct testing:
|
|
119
|
+
function notifySignup(opts: { name: string; email: string; domain: string; orgName: string; isNewOrg: boolean }): void {
|
|
120
|
+
const botToken = process.env.SIGNUP_SLACK_BOT_TOKEN;
|
|
121
|
+
if (!botToken) return;
|
|
122
|
+
|
|
123
|
+
const emoji = opts.isNewOrg ? ":tada:" : ":wave:";
|
|
124
|
+
const action = opts.isNewOrg ? "signed up (new org)" : "joined";
|
|
125
|
+
const text = `${emoji} *${opts.name}* (${opts.email}) ${action} — ${opts.orgName} (${opts.domain})`;
|
|
126
|
+
|
|
127
|
+
fetch("https://slack.com/api/chat.postMessage", {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: `Bearer ${botToken}`,
|
|
131
|
+
"Content-Type": "application/json",
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify({ channel: "#alerts-mql-stream", text }),
|
|
134
|
+
}).catch(() => {});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describe("notifySignup — new org", () => {
|
|
138
|
+
test("posts to #alerts-mql-stream with new org emoji and details", async () => {
|
|
139
|
+
process.env.SIGNUP_SLACK_BOT_TOKEN = "xoxb-test-token";
|
|
140
|
+
|
|
141
|
+
notifySignup({ name: "Alice Smith", email: "alice@newco.com", domain: "newco.com", orgName: "Newco", isNewOrg: true });
|
|
142
|
+
|
|
143
|
+
// Wait for the async fetch to resolve
|
|
144
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
145
|
+
|
|
146
|
+
const call = slackCalls.find((c) => c.body.text?.toString().includes("Alice Smith"));
|
|
147
|
+
expect(call).toBeDefined();
|
|
148
|
+
expect(call!.body.channel).toBe("#alerts-mql-stream");
|
|
149
|
+
expect(call!.body.text).toContain(":tada:");
|
|
150
|
+
expect(call!.body.text).toContain("signed up (new org)");
|
|
151
|
+
expect(call!.body.text).toContain("alice@newco.com");
|
|
152
|
+
expect(call!.body.text).toContain("Newco");
|
|
153
|
+
expect(call!.body.text).toContain("newco.com");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("notifySignup — join existing org", () => {
|
|
158
|
+
test("posts to #alerts-mql-stream with join emoji", async () => {
|
|
159
|
+
process.env.SIGNUP_SLACK_BOT_TOKEN = "xoxb-test-token";
|
|
160
|
+
|
|
161
|
+
notifySignup({ name: "Bob Jones", email: "bob@acme.com", domain: "acme.com", orgName: "Acme", isNewOrg: false });
|
|
162
|
+
|
|
163
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
164
|
+
|
|
165
|
+
const call = slackCalls.find((c) => c.body.text?.toString().includes("Bob Jones"));
|
|
166
|
+
expect(call).toBeDefined();
|
|
167
|
+
expect(call!.body.channel).toBe("#alerts-mql-stream");
|
|
168
|
+
expect(call!.body.text).toContain(":wave:");
|
|
169
|
+
expect(call!.body.text).toContain("joined");
|
|
170
|
+
expect(call!.body.text).not.toContain("signed up");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("notifySignup — no token", () => {
|
|
175
|
+
test("does not post when SIGNUP_SLACK_BOT_TOKEN is not set", async () => {
|
|
176
|
+
delete process.env.SIGNUP_SLACK_BOT_TOKEN;
|
|
177
|
+
|
|
178
|
+
notifySignup({ name: "Ghost User", email: "ghost@nowhere.com", domain: "nowhere.com", orgName: "Nowhere", isNewOrg: true });
|
|
179
|
+
|
|
180
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
181
|
+
|
|
182
|
+
const call = slackCalls.find((c) => c.body.text?.toString().includes("Ghost User"));
|
|
183
|
+
expect(call).toBeUndefined();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("notifySignup — Slack API uses correct auth header", () => {
|
|
188
|
+
test("sends Bearer token in Authorization header", async () => {
|
|
189
|
+
process.env.SIGNUP_SLACK_BOT_TOKEN = "xoxb-my-secret-token";
|
|
190
|
+
|
|
191
|
+
// Override fetch to also capture headers
|
|
192
|
+
const capturedHeaders: Record<string, string>[] = [];
|
|
193
|
+
const prevFetch = globalThis.fetch;
|
|
194
|
+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
195
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
196
|
+
if (url.startsWith("https://slack.com/api/")) {
|
|
197
|
+
const headers: Record<string, string> = {};
|
|
198
|
+
if (init?.headers) {
|
|
199
|
+
const h = init.headers as Record<string, string>;
|
|
200
|
+
for (const [k, v] of Object.entries(h)) headers[k] = v;
|
|
201
|
+
}
|
|
202
|
+
capturedHeaders.push(headers);
|
|
203
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
204
|
+
headers: { "Content-Type": "application/json" },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return prevFetch(input, init);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
notifySignup({ name: "Test", email: "test@test.com", domain: "test.com", orgName: "Test", isNewOrg: true });
|
|
211
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
212
|
+
|
|
213
|
+
expect(capturedHeaders.length).toBeGreaterThan(0);
|
|
214
|
+
expect(capturedHeaders[0].Authorization).toBe("Bearer xoxb-my-secret-token");
|
|
215
|
+
|
|
216
|
+
globalThis.fetch = prevFetch;
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// --- Rollup ---
|
|
221
|
+
|
|
222
|
+
describe("signup rollup message format", () => {
|
|
223
|
+
test("builds correct rollup from recent signups", async () => {
|
|
224
|
+
process.env.SIGNUP_SLACK_BOT_TOKEN = "xoxb-test-token";
|
|
225
|
+
|
|
226
|
+
await createOrg(sql, "org1", "Acme", "acme.com");
|
|
227
|
+
await createOrg(sql, "org2", "Startup", "startup.io");
|
|
228
|
+
await createUser(sql, "u1", "alice@acme.com", "Alice Smith", "org1", "user:alice");
|
|
229
|
+
await createUser(sql, "u2", "bob@startup.io", "Bob Jones", "org2", "user:bob");
|
|
230
|
+
await createUser(sql, "u3", "carol@acme.com", "Carol Lee", "org1", "user:carol");
|
|
231
|
+
|
|
232
|
+
const since = new Date(Date.now() - 60 * 60 * 1000);
|
|
233
|
+
const signups = await getRecentSignups(sql, since, 10);
|
|
234
|
+
|
|
235
|
+
expect(signups).toHaveLength(3);
|
|
236
|
+
|
|
237
|
+
// Simulate building the rollup message (same logic as in app.ts)
|
|
238
|
+
const lines = signups.map((s) => `• *${s.name}* (${s.email}) — ${s.org_name}`);
|
|
239
|
+
const text = `:chart_with_upwards_trend: *${signups.length} signup${signups.length === 1 ? "" : "s"} in the last hour*\n${lines.join("\n")}`;
|
|
240
|
+
|
|
241
|
+
expect(text).toContain("3 signups in the last hour");
|
|
242
|
+
expect(text).toContain("Alice Smith");
|
|
243
|
+
expect(text).toContain("Bob Jones");
|
|
244
|
+
expect(text).toContain("Carol Lee");
|
|
245
|
+
expect(text).toContain("Acme");
|
|
246
|
+
expect(text).toContain("Startup");
|
|
247
|
+
|
|
248
|
+
// Now actually post via the same Slack API path
|
|
249
|
+
fetch("https://slack.com/api/chat.postMessage", {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: {
|
|
252
|
+
Authorization: `Bearer ${process.env.SIGNUP_SLACK_BOT_TOKEN}`,
|
|
253
|
+
"Content-Type": "application/json",
|
|
254
|
+
},
|
|
255
|
+
body: JSON.stringify({ channel: "#alerts-mql-stream", text }),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
259
|
+
|
|
260
|
+
const rollupCall = slackCalls.find((c) => c.body.text?.toString().includes("signups in the last hour"));
|
|
261
|
+
expect(rollupCall).toBeDefined();
|
|
262
|
+
expect(rollupCall!.body.channel).toBe("#alerts-mql-stream");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("singular form for single signup", async () => {
|
|
266
|
+
await createOrg(sql, "org1", "Acme", "acme.com");
|
|
267
|
+
await createUser(sql, "u1", "solo@acme.com", "Solo User", "org1", "user:solo");
|
|
268
|
+
|
|
269
|
+
const since = new Date(Date.now() - 60 * 60 * 1000);
|
|
270
|
+
const signups = await getRecentSignups(sql, since, 10);
|
|
271
|
+
const text = `:chart_with_upwards_trend: *${signups.length} signup${signups.length === 1 ? "" : "s"} in the last hour*`;
|
|
272
|
+
|
|
273
|
+
expect(text).toContain("1 signup in the last hour");
|
|
274
|
+
expect(text).not.toContain("signups");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("empty rollup produces no Slack call", async () => {
|
|
278
|
+
const since = new Date(Date.now() - 60 * 60 * 1000);
|
|
279
|
+
const signups = await getRecentSignups(sql, since, 10);
|
|
280
|
+
|
|
281
|
+
expect(signups).toHaveLength(0);
|
|
282
|
+
// In the real code, empty signups = early return, no Slack call
|
|
283
|
+
// Verify no calls were made
|
|
284
|
+
expect(slackCalls).toHaveLength(0);
|
|
285
|
+
});
|
|
286
|
+
});
|