@devwithbobby/loops 0.1.1 → 0.1.2
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.
- package/dist/client/index.d.ts +186 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +396 -0
- package/dist/client/types.d.ts +24 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +0 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +25 -0
- package/dist/component/lib.d.ts +103 -0
- package/dist/component/lib.d.ts.map +1 -0
- package/dist/component/lib.js +1000 -0
- package/dist/component/schema.d.ts +3 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +16 -0
- package/dist/component/tables/contacts.d.ts +2 -0
- package/dist/component/tables/contacts.d.ts.map +1 -0
- package/dist/component/tables/contacts.js +14 -0
- package/dist/component/tables/emailOperations.d.ts +2 -0
- package/dist/component/tables/emailOperations.d.ts.map +1 -0
- package/dist/component/tables/emailOperations.js +20 -0
- package/dist/component/validators.d.ts +18 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +34 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +5 -0
- package/package.json +10 -4
- package/.config/commitlint.config.ts +0 -11
- package/.config/lefthook.yml +0 -11
- package/.github/workflows/release.yml +0 -52
- package/.github/workflows/test-and-lint.yml +0 -39
- package/biome.json +0 -45
- package/bun.lock +0 -1166
- package/bunfig.toml +0 -7
- package/convex.json +0 -3
- package/example/CLAUDE.md +0 -106
- package/example/README.md +0 -21
- package/example/bun-env.d.ts +0 -17
- package/example/convex/_generated/api.d.ts +0 -53
- package/example/convex/_generated/api.js +0 -23
- package/example/convex/_generated/dataModel.d.ts +0 -60
- package/example/convex/_generated/server.d.ts +0 -149
- package/example/convex/_generated/server.js +0 -90
- package/example/convex/convex.config.ts +0 -7
- package/example/convex/example.ts +0 -76
- package/example/convex/schema.ts +0 -3
- package/example/convex/tsconfig.json +0 -34
- package/example/src/App.tsx +0 -185
- package/example/src/frontend.tsx +0 -39
- package/example/src/index.css +0 -15
- package/example/src/index.html +0 -12
- package/example/src/index.tsx +0 -19
- package/example/tsconfig.json +0 -28
- package/renovate.json +0 -32
- package/test/client/_generated/_ignore.ts +0 -1
- package/test/client/index.test.ts +0 -65
- package/test/client/setup.test.ts +0 -54
- package/test/component/lib.test.ts +0 -225
- package/test/component/setup.test.ts +0 -21
- package/tsconfig.build.json +0 -20
- package/tsconfig.json +0 -22
package/example/src/App.tsx
DELETED
|
@@ -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;
|
package/example/src/frontend.tsx
DELETED
|
@@ -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
|
-
}
|
package/example/src/index.css
DELETED
package/example/src/index.html
DELETED
|
@@ -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>
|
package/example/src/index.tsx
DELETED
|
@@ -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}`);
|
package/example/tsconfig.json
DELETED
|
@@ -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
|
-
});
|