@devwithbobby/loops 0.1.0
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/.changeset/README.md +8 -0
- package/.changeset/config.json +14 -0
- package/.config/commitlint.config.ts +11 -0
- package/.config/lefthook.yml +11 -0
- package/.github/workflows/release.yml +52 -0
- package/.github/workflows/test-and-lint.yml +39 -0
- package/README.md +517 -0
- package/biome.json +45 -0
- package/bun.lock +1166 -0
- package/bunfig.toml +7 -0
- package/convex.json +3 -0
- package/example/CLAUDE.md +106 -0
- package/example/README.md +21 -0
- package/example/bun-env.d.ts +17 -0
- package/example/convex/_generated/api.d.ts +53 -0
- package/example/convex/_generated/api.js +23 -0
- package/example/convex/_generated/dataModel.d.ts +60 -0
- package/example/convex/_generated/server.d.ts +149 -0
- package/example/convex/_generated/server.js +90 -0
- package/example/convex/convex.config.ts +7 -0
- package/example/convex/example.ts +76 -0
- package/example/convex/schema.ts +3 -0
- package/example/convex/tsconfig.json +34 -0
- package/example/src/App.tsx +185 -0
- package/example/src/frontend.tsx +39 -0
- package/example/src/index.css +15 -0
- package/example/src/index.html +12 -0
- package/example/src/index.tsx +19 -0
- package/example/tsconfig.json +28 -0
- package/package.json +95 -0
- package/prds/CHANGELOG.md +38 -0
- package/prds/CLAUDE.md +408 -0
- package/prds/CONTRIBUTING.md +274 -0
- package/prds/ENV_SETUP.md +222 -0
- package/prds/MONITORING.md +301 -0
- package/prds/RATE_LIMITING.md +412 -0
- package/prds/SECURITY.md +246 -0
- package/renovate.json +32 -0
- package/src/client/index.ts +530 -0
- package/src/client/types.ts +64 -0
- package/src/component/_generated/api.d.ts +55 -0
- package/src/component/_generated/api.js +23 -0
- package/src/component/_generated/dataModel.d.ts +60 -0
- package/src/component/_generated/server.d.ts +149 -0
- package/src/component/_generated/server.js +90 -0
- package/src/component/convex.config.ts +27 -0
- package/src/component/lib.ts +1125 -0
- package/src/component/schema.ts +17 -0
- package/src/component/tables/contacts.ts +16 -0
- package/src/component/tables/emailOperations.ts +22 -0
- package/src/component/validators.ts +39 -0
- package/src/utils.ts +6 -0
- package/test/client/_generated/_ignore.ts +1 -0
- package/test/client/index.test.ts +65 -0
- package/test/client/setup.test.ts +54 -0
- package/test/component/lib.test.ts +225 -0
- package/test/component/setup.test.ts +21 -0
- package/tsconfig.build.json +20 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineSchema } from "convex/server";
|
|
2
|
+
import { Contacts } from "./tables/contacts";
|
|
3
|
+
import { EmailOperations } from "./tables/emailOperations";
|
|
4
|
+
|
|
5
|
+
export default defineSchema({
|
|
6
|
+
contacts: Contacts.table
|
|
7
|
+
.index("email", ["email"])
|
|
8
|
+
.index("userId", ["userId"])
|
|
9
|
+
.index("userGroup", ["userGroup"])
|
|
10
|
+
.index("source", ["source"])
|
|
11
|
+
.index("subscribed", ["subscribed"]),
|
|
12
|
+
emailOperations: EmailOperations.table
|
|
13
|
+
.index("email", ["email", "timestamp"])
|
|
14
|
+
.index("actorId", ["actorId", "timestamp"])
|
|
15
|
+
.index("operationType", ["operationType", "timestamp"])
|
|
16
|
+
.index("timestamp", ["timestamp"]),
|
|
17
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { zodTable } from "zodvex";
|
|
3
|
+
|
|
4
|
+
export const Contacts = zodTable("contacts", {
|
|
5
|
+
email: z.string().email(),
|
|
6
|
+
firstName: z.string().optional(),
|
|
7
|
+
lastName: z.string().optional(),
|
|
8
|
+
userId: z.string().optional(),
|
|
9
|
+
source: z.string().optional(),
|
|
10
|
+
subscribed: z.boolean().default(true),
|
|
11
|
+
userGroup: z.string().optional(),
|
|
12
|
+
loopsContactId: z.string().optional(),
|
|
13
|
+
createdAt: z.number(),
|
|
14
|
+
updatedAt: z.number(),
|
|
15
|
+
});
|
|
16
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { zodTable } from "zodvex";
|
|
3
|
+
|
|
4
|
+
export const EmailOperations = zodTable("emailOperations", {
|
|
5
|
+
operationType: z.enum([
|
|
6
|
+
"transactional",
|
|
7
|
+
"event",
|
|
8
|
+
"campaign",
|
|
9
|
+
"loop",
|
|
10
|
+
]),
|
|
11
|
+
email: z.string().email(),
|
|
12
|
+
actorId: z.string().optional(),
|
|
13
|
+
transactionalId: z.string().optional(),
|
|
14
|
+
campaignId: z.string().optional(),
|
|
15
|
+
loopId: z.string().optional(),
|
|
16
|
+
eventName: z.string().optional(),
|
|
17
|
+
timestamp: z.number(),
|
|
18
|
+
success: z.boolean(),
|
|
19
|
+
messageId: z.string().optional(),
|
|
20
|
+
metadata: z.optional(z.record(z.string(), z.any())),
|
|
21
|
+
});
|
|
22
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validators for Loops API requests and component operations
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validator for contact data
|
|
9
|
+
* Used for creating and updating contacts
|
|
10
|
+
*/
|
|
11
|
+
export const contactValidator = z.object({
|
|
12
|
+
email: z.string().email(),
|
|
13
|
+
firstName: z.string().optional(),
|
|
14
|
+
lastName: z.string().optional(),
|
|
15
|
+
userId: z.string().optional(),
|
|
16
|
+
source: z.string().optional(),
|
|
17
|
+
subscribed: z.boolean().optional(),
|
|
18
|
+
userGroup: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validator for transactional email requests
|
|
23
|
+
*/
|
|
24
|
+
export const transactionalEmailValidator = z.object({
|
|
25
|
+
transactionalId: z.string().optional(),
|
|
26
|
+
email: z.string().email(),
|
|
27
|
+
dataVariables: z.record(z.string(), z.any()).optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validator for event requests
|
|
32
|
+
* Used for sending events that trigger email workflows
|
|
33
|
+
*/
|
|
34
|
+
export const eventValidator = z.object({
|
|
35
|
+
email: z.string().email(),
|
|
36
|
+
eventName: z.string(),
|
|
37
|
+
eventProperties: z.record(z.string(), z.any()).optional(),
|
|
38
|
+
});
|
|
39
|
+
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { zActionBuilder, zMutationBuilder, zQueryBuilder } from "zodvex";
|
|
2
|
+
import { action, mutation, query } from "./component/_generated/server";
|
|
3
|
+
|
|
4
|
+
export const zq = zQueryBuilder(query);
|
|
5
|
+
export const zm = zMutationBuilder(mutation);
|
|
6
|
+
export const za = zActionBuilder(action);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// This is only here so convex-test can detect a _generated folder
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
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", () => {});
|
|
@@ -0,0 +1,225 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { convexTest as convexT } from "convex-test";
|
|
4
|
+
import schema from "../../src/component/schema.ts";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const componentDir = join(__dirname, "../../src/component");
|
|
8
|
+
|
|
9
|
+
const files = await Array.fromAsync(
|
|
10
|
+
new Bun.Glob("**/*.{ts,js}").scan({ cwd: componentDir }),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export const modules = Object.fromEntries(
|
|
14
|
+
files
|
|
15
|
+
.filter((f) => !f.includes(".test."))
|
|
16
|
+
.map((f) => [`./${f}`, () => import(join(componentDir, f))]),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export const convexTest = () => {
|
|
20
|
+
return convexT(schema, modules);
|
|
21
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": false,
|
|
5
|
+
"allowImportingTsExtensions": false,
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"outDir": "./dist",
|
|
9
|
+
"rootDir": "./src",
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"noImplicitAny": false,
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"],
|
|
14
|
+
"exclude": [
|
|
15
|
+
"src/**/*.test.*",
|
|
16
|
+
"src/**/test-setup.ts",
|
|
17
|
+
"src/**/*.convex.test.*",
|
|
18
|
+
"src/**/_generated"
|
|
19
|
+
]
|
|
20
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext"],
|
|
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
|
+
"noUnusedLocals": false,
|
|
19
|
+
"noUnusedParameters": false,
|
|
20
|
+
"noPropertyAccessFromIndexSignature": false
|
|
21
|
+
}
|
|
22
|
+
}
|