@devwithbobby/loops 0.1.1 → 0.1.3

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.
Files changed (62) hide show
  1. package/dist/client/index.d.ts +186 -0
  2. package/dist/client/index.d.ts.map +1 -0
  3. package/dist/client/index.js +396 -0
  4. package/dist/client/types.d.ts +24 -0
  5. package/dist/client/types.d.ts.map +1 -0
  6. package/dist/client/types.js +0 -0
  7. package/dist/component/convex.config.d.ts +3 -0
  8. package/dist/component/convex.config.d.ts.map +1 -0
  9. package/dist/component/convex.config.js +25 -0
  10. package/dist/component/lib.d.ts +103 -0
  11. package/dist/component/lib.d.ts.map +1 -0
  12. package/dist/component/lib.js +1000 -0
  13. package/dist/component/schema.d.ts +3 -0
  14. package/dist/component/schema.d.ts.map +1 -0
  15. package/dist/component/schema.js +16 -0
  16. package/dist/component/tables/contacts.d.ts +2 -0
  17. package/dist/component/tables/contacts.d.ts.map +1 -0
  18. package/dist/component/tables/contacts.js +14 -0
  19. package/dist/component/tables/emailOperations.d.ts +2 -0
  20. package/dist/component/tables/emailOperations.d.ts.map +1 -0
  21. package/dist/component/tables/emailOperations.js +20 -0
  22. package/dist/component/validators.d.ts +18 -0
  23. package/dist/component/validators.d.ts.map +1 -0
  24. package/dist/component/validators.js +34 -0
  25. package/dist/utils.d.ts +4 -0
  26. package/dist/utils.d.ts.map +1 -0
  27. package/dist/utils.js +5 -0
  28. package/package.json +11 -5
  29. package/.config/commitlint.config.ts +0 -11
  30. package/.config/lefthook.yml +0 -11
  31. package/.github/workflows/release.yml +0 -52
  32. package/.github/workflows/test-and-lint.yml +0 -39
  33. package/biome.json +0 -45
  34. package/bun.lock +0 -1166
  35. package/bunfig.toml +0 -7
  36. package/convex.json +0 -3
  37. package/example/CLAUDE.md +0 -106
  38. package/example/README.md +0 -21
  39. package/example/bun-env.d.ts +0 -17
  40. package/example/convex/_generated/api.d.ts +0 -53
  41. package/example/convex/_generated/api.js +0 -23
  42. package/example/convex/_generated/dataModel.d.ts +0 -60
  43. package/example/convex/_generated/server.d.ts +0 -149
  44. package/example/convex/_generated/server.js +0 -90
  45. package/example/convex/convex.config.ts +0 -7
  46. package/example/convex/example.ts +0 -76
  47. package/example/convex/schema.ts +0 -3
  48. package/example/convex/tsconfig.json +0 -34
  49. package/example/src/App.tsx +0 -185
  50. package/example/src/frontend.tsx +0 -39
  51. package/example/src/index.css +0 -15
  52. package/example/src/index.html +0 -12
  53. package/example/src/index.tsx +0 -19
  54. package/example/tsconfig.json +0 -28
  55. package/renovate.json +0 -32
  56. package/test/client/_generated/_ignore.ts +0 -1
  57. package/test/client/index.test.ts +0 -65
  58. package/test/client/setup.test.ts +0 -54
  59. package/test/component/lib.test.ts +0 -225
  60. package/test/component/setup.test.ts +0 -21
  61. package/tsconfig.build.json +0 -20
  62. package/tsconfig.json +0 -22
@@ -1,185 +0,0 @@
1
- import { useAction } from "convex/react";
2
- import { api } from "../convex/_generated/api";
3
- import { useState } from "react";
4
- import "./index.css";
5
-
6
- export function App() {
7
- const [email, setEmail] = useState("");
8
- const [firstName, setFirstName] = useState("");
9
- const [lastName, setLastName] = useState("");
10
- const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
11
-
12
- const addContact = useAction(api.example.addContact);
13
- const sendEvent = useAction(api.example.sendEvent);
14
- const [isLoading, setIsLoading] = useState(false);
15
-
16
- const handleAddContact = async (e: React.FormEvent) => {
17
- e.preventDefault();
18
- setIsLoading(true);
19
- setMessage(null);
20
-
21
- try {
22
- await addContact({
23
- email,
24
- firstName: firstName || undefined,
25
- lastName: lastName || undefined,
26
- });
27
- setMessage({ type: "success", text: "Contact added successfully!" });
28
- setEmail("");
29
- setFirstName("");
30
- setLastName("");
31
- } catch (error) {
32
- setMessage({
33
- type: "error",
34
- text: error instanceof Error ? error.message : "Failed to add contact",
35
- });
36
- } finally {
37
- setIsLoading(false);
38
- }
39
- };
40
-
41
- const handleSendEvent = async () => {
42
- if (!email) {
43
- setMessage({ type: "error", text: "Please enter an email first" });
44
- return;
45
- }
46
-
47
- setIsLoading(true);
48
- setMessage(null);
49
-
50
- try {
51
- await sendEvent({
52
- email,
53
- eventName: "welcome",
54
- eventProperties: {
55
- firstName: firstName || undefined,
56
- lastName: lastName || undefined,
57
- },
58
- });
59
- setMessage({ type: "success", text: "Event sent successfully!" });
60
- } catch (error) {
61
- setMessage({
62
- type: "error",
63
- text: error instanceof Error ? error.message : "Failed to send event",
64
- });
65
- } finally {
66
- setIsLoading(false);
67
- }
68
- };
69
-
70
- return (
71
- <div className="min-h-screen bg-gray-50 w-full flex items-center justify-center p-4">
72
- <div className="container mx-auto max-w-md">
73
- <div className="bg-white rounded-lg shadow border border-gray-200 p-8">
74
- <div className="text-center mb-8">
75
- <h1 className="text-3xl font-bold text-gray-900 mb-2">
76
- Loops Component
77
- </h1>
78
- <p className="text-gray-600 text-sm">
79
- Powered by Convex Components & Loops.so
80
- </p>
81
- </div>
82
-
83
- <form onSubmit={handleAddContact} className="space-y-4 mb-6">
84
- <div>
85
- <label
86
- htmlFor="email"
87
- className="block text-sm font-medium text-gray-700 mb-1"
88
- >
89
- Email *
90
- </label>
91
- <input
92
- id="email"
93
- type="email"
94
- required
95
- value={email}
96
- onChange={(e) => setEmail(e.target.value)}
97
- className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
98
- placeholder="user@example.com"
99
- />
100
- </div>
101
-
102
- <div>
103
- <label
104
- htmlFor="firstName"
105
- className="block text-sm font-medium text-gray-700 mb-1"
106
- >
107
- First Name
108
- </label>
109
- <input
110
- id="firstName"
111
- type="text"
112
- value={firstName}
113
- onChange={(e) => setFirstName(e.target.value)}
114
- className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
115
- placeholder="John"
116
- />
117
- </div>
118
-
119
- <div>
120
- <label
121
- htmlFor="lastName"
122
- className="block text-sm font-medium text-gray-700 mb-1"
123
- >
124
- Last Name
125
- </label>
126
- <input
127
- id="lastName"
128
- type="text"
129
- value={lastName}
130
- onChange={(e) => setLastName(e.target.value)}
131
- className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
132
- placeholder="Doe"
133
- />
134
- </div>
135
-
136
- <button
137
- type="submit"
138
- disabled={isLoading}
139
- className="w-full bg-blue-600 text-white font-medium px-6 py-2 rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
140
- >
141
- {isLoading ? "Adding..." : "Add Contact"}
142
- </button>
143
- </form>
144
-
145
- <div className="mb-6">
146
- <button
147
- type="button"
148
- onClick={handleSendEvent}
149
- disabled={isLoading || !email}
150
- className="w-full bg-green-600 text-white font-medium px-6 py-2 rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
151
- >
152
- Send Welcome Event
153
- </button>
154
- </div>
155
-
156
- {message && (
157
- <div
158
- className={`p-4 rounded-md mb-4 ${
159
- message.type === "success"
160
- ? "bg-green-50 border border-green-200 text-green-800"
161
- : "bg-red-50 border border-red-200 text-red-800"
162
- }`}
163
- >
164
- {message.text}
165
- </div>
166
- )}
167
-
168
- <div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
169
- <p className="text-sm text-gray-700 text-center">
170
- <code className="text-blue-600 font-mono text-xs">
171
- example/convex/example.ts
172
- </code>
173
- <br />
174
- <span className="text-gray-500 text-xs">
175
- Check out the code to see all available features
176
- </span>
177
- </p>
178
- </div>
179
- </div>
180
- </div>
181
- </div>
182
- );
183
- }
184
-
185
- export default App;
@@ -1,39 +0,0 @@
1
- /**
2
- * This file is the entry point for the React app, it sets up the root
3
- * element and renders the App component to the DOM.
4
- *
5
- * It is included in `src/index.html`.
6
- */
7
-
8
- import { ConvexProvider, ConvexReactClient } from "convex/react";
9
- import { createRoot } from "react-dom/client";
10
- import { App } from "./App";
11
-
12
- const convexURL = process.env.CONVEX_URL;
13
-
14
- if (!convexURL) {
15
- throw new Error("No convex URL provided!");
16
- }
17
-
18
- const convex = new ConvexReactClient(convexURL);
19
-
20
- function start() {
21
- const rootElement = document.getElementById("root");
22
-
23
- if (!rootElement) {
24
- throw new Error("Could not find root");
25
- }
26
-
27
- const root = createRoot(rootElement);
28
- root.render(
29
- <ConvexProvider client={convex}>
30
- <App />
31
- </ConvexProvider>,
32
- );
33
- }
34
-
35
- if (document.readyState === "loading") {
36
- document.addEventListener("DOMContentLoaded", start);
37
- } else {
38
- start();
39
- }
@@ -1,15 +0,0 @@
1
- @import "tailwindcss";
2
-
3
- @layer base {
4
- :root {
5
- @apply font-sans;
6
- }
7
-
8
- body {
9
- @apply w-full min-h-screen m-0;
10
- }
11
-
12
- #root {
13
- @apply w-full;
14
- }
15
- }
@@ -1,12 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Loops Component Example</title>
7
- </head>
8
- <body>
9
- <div id="root"></div>
10
- <script type="module" src="./frontend.tsx"></script>
11
- </body>
12
- </html>
@@ -1,19 +0,0 @@
1
- import { serve } from "bun";
2
- import index from "./index.html";
3
-
4
- const server = serve({
5
- routes: {
6
- // Serve index.html for all unmatched routes.
7
- "/*": index,
8
- },
9
-
10
- development: process.env.NODE_ENV !== "production" && {
11
- // Enable browser hot reloading in development
12
- hmr: true,
13
-
14
- // Echo console logs from the browser to the server
15
- console: true,
16
- },
17
- });
18
-
19
- console.log(`🚀 Server running at ${server.url}`);
@@ -1,28 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "lib": ["ESNext", "DOM"],
4
- "target": "ESNext",
5
- "module": "Preserve",
6
- "moduleDetection": "force",
7
- "jsx": "react-jsx",
8
- "allowJs": true,
9
- "moduleResolution": "bundler",
10
- "allowImportingTsExtensions": true,
11
- "verbatimModuleSyntax": true,
12
- "noEmit": true,
13
- "strict": true,
14
- "skipLibCheck": true,
15
- "noFallthroughCasesInSwitch": true,
16
- "noUncheckedIndexedAccess": true,
17
- "noImplicitOverride": true,
18
- "baseUrl": ".",
19
- "paths": {
20
- "@/*": ["./src/*"]
21
- },
22
- "noUnusedLocals": false,
23
- "noUnusedParameters": false,
24
- "noPropertyAccessFromIndexSignature": false
25
- },
26
-
27
- "exclude": ["dist", "node_modules"]
28
- }
package/renovate.json DELETED
@@ -1,32 +0,0 @@
1
- {
2
- "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
- "extends": ["config:best-practices"],
4
- "schedule": ["* 0-4 * * 1"],
5
- "timezone": "America/Los_Angeles",
6
- "prConcurrentLimit": 1,
7
- "packageRules": [
8
- {
9
- "groupName": "Convex packages",
10
- "matchPackagePatterns": ["^convex"],
11
- "automerge": false,
12
- "description": "Keep Convex packages together but require manual review"
13
- },
14
- {
15
- "groupName": "Routine updates",
16
- "matchUpdateTypes": ["minor", "patch", "pin", "digest"],
17
- "excludePackagePatterns": ["^convex"],
18
- "automerge": true
19
- },
20
- {
21
- "groupName": "Major updates",
22
- "matchUpdateTypes": ["major"],
23
- "excludePackagePatterns": ["^convex"],
24
- "automerge": false
25
- },
26
- {
27
- "matchDepTypes": ["devDependencies"],
28
- "excludePackagePatterns": ["^convex"],
29
- "automerge": true
30
- }
31
- ]
32
- }
@@ -1 +0,0 @@
1
- // This is only here so convex-test can detect a _generated folder
@@ -1,65 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { defineSchema } from "convex/server";
3
- import { Loops } from "../../src/client";
4
- import { components, initConvexTest } from "./setup.test.js";
5
-
6
- // The schema for the tests
7
- const schema = defineSchema({});
8
-
9
- describe("Loops thick client", () => {
10
- test("should create Loops client", () => {
11
- const loops = new Loops(components.loops, {
12
- apiKey: "test-api-key",
13
- });
14
- expect(loops).toBeDefined();
15
- });
16
-
17
- test("should throw error if no API key provided", () => {
18
- const originalEnv = process.env.LOOPS_API_KEY;
19
- delete process.env.LOOPS_API_KEY;
20
-
21
- expect(() => {
22
- new Loops(components.loops);
23
- }).toThrow("Loops API key is required");
24
-
25
- if (originalEnv) {
26
- process.env.LOOPS_API_KEY = originalEnv;
27
- }
28
- });
29
-
30
- test("should work with api() helper - generates actions", () => {
31
- const loops = new Loops(components.loops, {
32
- apiKey: "test-api-key",
33
- });
34
- const api = loops.api();
35
-
36
- expect(api.addContact).toBeDefined();
37
- expect(api.updateContact).toBeDefined();
38
- expect(api.findContact).toBeDefined();
39
- expect(api.batchCreateContacts).toBeDefined();
40
- expect(api.unsubscribeContact).toBeDefined();
41
- expect(api.resubscribeContact).toBeDefined();
42
- expect(api.deleteContact).toBeDefined();
43
- expect(api.sendTransactional).toBeDefined();
44
- expect(api.sendEvent).toBeDefined();
45
- expect(api.sendCampaign).toBeDefined();
46
- expect(api.triggerLoop).toBeDefined();
47
- expect(api.countContacts).toBeDefined();
48
- expect(api.detectRecipientSpam).toBeDefined();
49
- expect(api.detectActorSpam).toBeDefined();
50
- expect(api.getEmailStats).toBeDefined();
51
- expect(api.detectRapidFirePatterns).toBeDefined();
52
- expect(api.checkRecipientRateLimit).toBeDefined();
53
- expect(api.checkActorRateLimit).toBeDefined();
54
- expect(api.checkGlobalRateLimit).toBeDefined();
55
- expect(typeof api.addContact).toBe("function");
56
- expect(typeof api.sendTransactional).toBe("function");
57
- expect(typeof api.countContacts).toBe("function");
58
- expect(typeof api.detectRecipientSpam).toBe("function");
59
- expect(typeof api.checkRecipientRateLimit).toBe("function");
60
- });
61
-
62
- // Note: Integration tests that actually call the Loops API would require
63
- // mocking the fetch calls or using a test API key. These tests verify
64
- // the structure and basic functionality of the client.
65
- });
@@ -1,54 +0,0 @@
1
- import { test } from "bun:test";
2
- import { dirname, join } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import {
5
- componentsGeneric,
6
- defineSchema,
7
- type GenericSchema,
8
- type SchemaDefinition,
9
- } from "convex/server";
10
- import { convexTest } from "convex-test";
11
- import type { LoopsComponentComponent } from "../../src/client/index.js";
12
- import componentSchema from "../../src/component/schema.js";
13
-
14
- const __dirname = dirname(fileURLToPath(import.meta.url));
15
- const clientDir = join(__dirname, "../../test/client");
16
- const componentDir = join(__dirname, "../../src/component");
17
-
18
- // Auto-discover client test files
19
- const clientFiles = await Array.fromAsync(
20
- new Bun.Glob("**/*.{ts,js}").scan({ cwd: clientDir }),
21
- );
22
-
23
- export const modules = Object.fromEntries(
24
- clientFiles
25
- .filter((f) => !f.includes(".test."))
26
- .map((f) => [`./${f}`, () => import(join(clientDir, f))]),
27
- );
28
-
29
- // Auto-discover component files for registration
30
- const componentFiles = await Array.fromAsync(
31
- new Bun.Glob("**/*.{ts,js}").scan({ cwd: componentDir }),
32
- );
33
-
34
- export const componentModules = Object.fromEntries(
35
- componentFiles
36
- .filter((f) => !f.includes(".test."))
37
- .map((f) => [`./${f}`, () => import(join(componentDir, f))]),
38
- );
39
-
40
- export { componentSchema };
41
-
42
- export function initConvexTest<
43
- Schema extends SchemaDefinition<GenericSchema, boolean>,
44
- >(schema?: Schema) {
45
- const t = convexTest(schema ?? defineSchema({}), modules);
46
- t.registerComponent("loops", componentSchema, componentModules);
47
- return t;
48
- }
49
-
50
- export const components = componentsGeneric() as unknown as {
51
- loops: LoopsComponentComponent;
52
- };
53
-
54
- test("setup", () => {});
@@ -1,225 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { api, internal } from "../../src/component/_generated/api";
3
- import { convexTest } from "./setup.test.ts";
4
-
5
- describe("component lib", () => {
6
- test("addContact stores contact in database", async () => {
7
- const t = convexTest();
8
- const result = await t.action(api.lib.addContact, {
9
- apiKey: "test-api-key",
10
- contact: {
11
- email: "test@example.com",
12
- firstName: "Test",
13
- lastName: "User",
14
- },
15
- });
16
-
17
- expect(result.success).toBe(true);
18
- expect(result.id).toBeDefined();
19
- const contacts = await t.db.query("contacts").collect();
20
- expect(contacts.length).toBe(1);
21
- expect(contacts[0].email).toBe("test@example.com");
22
- expect(contacts[0].firstName).toBe("Test");
23
- expect(contacts[0].lastName).toBe("User");
24
- });
25
-
26
- test("updateContact updates existing contact", async () => {
27
- const t = convexTest();
28
- await t.action(api.lib.addContact, {
29
- apiKey: "test-api-key",
30
- contact: {
31
- email: "update@example.com",
32
- firstName: "Original",
33
- },
34
- });
35
-
36
- const result = await t.action(api.lib.updateContact, {
37
- apiKey: "test-api-key",
38
- email: "update@example.com",
39
- firstName: "Updated",
40
- });
41
-
42
- expect(result.success).toBe(true);
43
-
44
- const contact = await t.db
45
- .query("contacts")
46
- .withIndex("email", (q: any) => q.eq("email", "update@example.com"))
47
- .unique();
48
- expect(contact?.firstName).toBe("Updated");
49
- });
50
-
51
- test("deleteContact removes contact from database", async () => {
52
- const t = convexTest();
53
-
54
- await t.action(api.lib.addContact, {
55
- apiKey: "test-api-key",
56
- contact: {
57
- email: "delete@example.com",
58
- },
59
- });
60
-
61
- let contacts = await t.db.query("contacts").collect();
62
- expect(contacts.length).toBe(1);
63
-
64
- const result = await t.action(api.lib.deleteContact, {
65
- apiKey: "test-api-key",
66
- email: "delete@example.com",
67
- });
68
-
69
- expect(result.success).toBe(true);
70
-
71
- contacts = await t.db.query("contacts").collect();
72
- expect(contacts.length).toBe(0);
73
- });
74
-
75
- test("countContacts returns correct count", async () => {
76
- const t = convexTest();
77
- const now = Date.now();
78
-
79
- await t.action(api.lib.addContact, {
80
- apiKey: "test-api-key",
81
- contact: {
82
- email: "count1@example.com",
83
- },
84
- });
85
- await t.action(api.lib.addContact, {
86
- apiKey: "test-api-key",
87
- contact: {
88
- email: "count2@example.com",
89
- },
90
- });
91
- await t.action(api.lib.addContact, {
92
- apiKey: "test-api-key",
93
- contact: {
94
- email: "count3@example.com",
95
- userGroup: "premium",
96
- },
97
- });
98
-
99
- const totalCount = await t.query(api.lib.countContacts, {});
100
- expect(totalCount).toBe(3);
101
-
102
- const premiumCount = await t.query(api.lib.countContacts, {
103
- userGroup: "premium",
104
- });
105
- expect(premiumCount).toBe(1);
106
-
107
- const subscribedCount = await t.query(api.lib.countContacts, {
108
- subscribed: true,
109
- });
110
- expect(subscribedCount).toBe(3);
111
- });
112
-
113
- test("checkRecipientRateLimit returns correct rate limit status", async () => {
114
- const t = convexTest();
115
-
116
- const now = Date.now();
117
-
118
- await t.mutation(internal.lib.logEmailOperation as any, {
119
- operationType: "transactional",
120
- email: "ratelimit@example.com",
121
- timestamp: now - 1000,
122
- success: true,
123
- });
124
- await t.mutation(internal.lib.logEmailOperation as any, {
125
- operationType: "transactional",
126
- email: "ratelimit@example.com",
127
- timestamp: now - 2000,
128
- success: true,
129
- });
130
-
131
- const check = await t.query(api.lib.checkRecipientRateLimit, {
132
- email: "ratelimit@example.com",
133
- timeWindowMs: 3600000,
134
- maxEmails: 10,
135
- });
136
-
137
- expect(check.allowed).toBe(true);
138
- expect(check.count).toBe(2);
139
- expect(check.limit).toBe(10);
140
- });
141
-
142
- test("checkRecipientRateLimit detects when limit is exceeded", async () => {
143
- const t = convexTest();
144
- const now = Date.now();
145
-
146
- for (let i = 0; i < 12; i++) {
147
- await t.mutation(internal.lib.logEmailOperation as any, {
148
- operationType: "transactional",
149
- email: "exceeded@example.com",
150
- timestamp: now - i * 1000,
151
- success: true,
152
- });
153
- }
154
-
155
- const check = await t.query(api.lib.checkRecipientRateLimit, {
156
- email: "exceeded@example.com",
157
- timeWindowMs: 3600000,
158
- maxEmails: 10,
159
- });
160
-
161
- expect(check.allowed).toBe(false);
162
- expect(check.count).toBeGreaterThan(10);
163
- expect(check.retryAfter).toBeDefined();
164
- });
165
-
166
- test("getEmailStats returns correct statistics", async () => {
167
- const t = convexTest();
168
- const now = Date.now();
169
-
170
- await t.mutation(internal.lib.logEmailOperation as any, {
171
- operationType: "transactional",
172
- email: "stats1@example.com",
173
- timestamp: now - 1000,
174
- success: true,
175
- });
176
- await t.mutation(internal.lib.logEmailOperation as any, {
177
- operationType: "event",
178
- email: "stats2@example.com",
179
- eventName: "test-event",
180
- timestamp: now - 2000,
181
- success: true,
182
- });
183
- await t.mutation(internal.lib.logEmailOperation as any, {
184
- operationType: "transactional",
185
- email: "stats3@example.com",
186
- timestamp: now - 3000,
187
- success: false,
188
- });
189
-
190
- const stats = await t.query(api.lib.getEmailStats, {
191
- timeWindowMs: 3600000,
192
- });
193
-
194
- expect(stats.totalOperations).toBe(3);
195
- expect(stats.successfulOperations).toBe(2);
196
- expect(stats.failedOperations).toBe(1);
197
- expect((stats.operationsByType as any)["transactional"]).toBe(2);
198
- expect((stats.operationsByType as any)["event"]).toBe(1);
199
- expect(stats.uniqueRecipients).toBe(3);
200
- });
201
-
202
- test("detectRecipientSpam finds suspicious patterns", async () => {
203
- const t = convexTest();
204
- const now = Date.now();
205
-
206
- for (let i = 0; i < 15; i++) {
207
- await t.mutation(internal.lib.logEmailOperation as any, {
208
- operationType: "transactional",
209
- email: "spam@example.com",
210
- timestamp: now - i * 1000,
211
- success: true,
212
- });
213
- }
214
-
215
- const spam = await t.query(api.lib.detectRecipientSpam, {
216
- timeWindowMs: 3600000,
217
- maxEmailsPerRecipient: 10,
218
- });
219
-
220
- expect(spam.length).toBeGreaterThan(0);
221
- const spamEntry = spam.find((s) => s.email === "spam@example.com");
222
- expect(spamEntry).toBeDefined();
223
- expect(spamEntry!.count).toBeGreaterThan(10);
224
- });
225
- });