outcome-cli 1.0.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/README.md +261 -0
- package/package.json +95 -0
- package/src/agents/README.md +139 -0
- package/src/agents/adapters/anthropic.adapter.ts +166 -0
- package/src/agents/adapters/dalle.adapter.ts +145 -0
- package/src/agents/adapters/gemini.adapter.ts +134 -0
- package/src/agents/adapters/imagen.adapter.ts +106 -0
- package/src/agents/adapters/nano-banana.adapter.ts +129 -0
- package/src/agents/adapters/openai.adapter.ts +165 -0
- package/src/agents/adapters/veo.adapter.ts +130 -0
- package/src/agents/agent.schema.property.test.ts +379 -0
- package/src/agents/agent.schema.test.ts +148 -0
- package/src/agents/agent.schema.ts +263 -0
- package/src/agents/index.ts +60 -0
- package/src/agents/registered-agent.schema.ts +356 -0
- package/src/agents/registry.ts +97 -0
- package/src/agents/tournament-configs.property.test.ts +266 -0
- package/src/cli/README.md +145 -0
- package/src/cli/commands/define.ts +79 -0
- package/src/cli/commands/list.ts +46 -0
- package/src/cli/commands/logs.ts +83 -0
- package/src/cli/commands/run.ts +416 -0
- package/src/cli/commands/verify.ts +110 -0
- package/src/cli/index.ts +81 -0
- package/src/config/README.md +128 -0
- package/src/config/env.ts +262 -0
- package/src/config/index.ts +19 -0
- package/src/eval/README.md +318 -0
- package/src/eval/ai-judge.test.ts +435 -0
- package/src/eval/ai-judge.ts +368 -0
- package/src/eval/code-validators.ts +414 -0
- package/src/eval/evaluateOutcome.property.test.ts +1174 -0
- package/src/eval/evaluateOutcome.ts +591 -0
- package/src/eval/immigration-validators.ts +122 -0
- package/src/eval/index.ts +90 -0
- package/src/eval/judge-cache.ts +402 -0
- package/src/eval/tournament-validators.property.test.ts +439 -0
- package/src/eval/validators.property.test.ts +1118 -0
- package/src/eval/validators.ts +1199 -0
- package/src/eval/weighted-scorer.ts +285 -0
- package/src/index.ts +17 -0
- package/src/league/README.md +188 -0
- package/src/league/health-check.ts +353 -0
- package/src/league/index.ts +93 -0
- package/src/league/killAgent.ts +151 -0
- package/src/league/league.test.ts +1151 -0
- package/src/league/runLeague.ts +843 -0
- package/src/league/scoreAgent.ts +175 -0
- package/src/modules/omnibridge/__tests__/.gitkeep +1 -0
- package/src/modules/omnibridge/__tests__/auth-tunnel.property.test.ts +524 -0
- package/src/modules/omnibridge/__tests__/deterministic-logger.property.test.ts +965 -0
- package/src/modules/omnibridge/__tests__/ghost-api.property.test.ts +461 -0
- package/src/modules/omnibridge/__tests__/omnibridge-integration.test.ts +542 -0
- package/src/modules/omnibridge/__tests__/parallel-executor.property.test.ts +671 -0
- package/src/modules/omnibridge/__tests__/semantic-normalizer.property.test.ts +521 -0
- package/src/modules/omnibridge/__tests__/semantic-normalizer.test.ts +254 -0
- package/src/modules/omnibridge/__tests__/session-vault.property.test.ts +367 -0
- package/src/modules/omnibridge/__tests__/shadow-session.property.test.ts +523 -0
- package/src/modules/omnibridge/__tests__/triangulation-engine.property.test.ts +292 -0
- package/src/modules/omnibridge/__tests__/verification-engine.property.test.ts +769 -0
- package/src/modules/omnibridge/api/.gitkeep +1 -0
- package/src/modules/omnibridge/api/ghost-api.ts +1087 -0
- package/src/modules/omnibridge/auth/.gitkeep +1 -0
- package/src/modules/omnibridge/auth/auth-tunnel.ts +843 -0
- package/src/modules/omnibridge/auth/session-vault.ts +577 -0
- package/src/modules/omnibridge/core/.gitkeep +1 -0
- package/src/modules/omnibridge/core/semantic-normalizer.ts +702 -0
- package/src/modules/omnibridge/core/triangulation-engine.ts +530 -0
- package/src/modules/omnibridge/core/types.ts +610 -0
- package/src/modules/omnibridge/execution/.gitkeep +1 -0
- package/src/modules/omnibridge/execution/deterministic-logger.ts +629 -0
- package/src/modules/omnibridge/execution/parallel-executor.ts +542 -0
- package/src/modules/omnibridge/execution/shadow-session.ts +794 -0
- package/src/modules/omnibridge/index.ts +212 -0
- package/src/modules/omnibridge/omnibridge.ts +510 -0
- package/src/modules/omnibridge/verification/.gitkeep +1 -0
- package/src/modules/omnibridge/verification/verification-engine.ts +783 -0
- package/src/outcomes/README.md +75 -0
- package/src/outcomes/acquire-pilot-customer.ts +297 -0
- package/src/outcomes/code-delivery-outcomes.ts +89 -0
- package/src/outcomes/code-outcomes.ts +256 -0
- package/src/outcomes/code_review_battle.test.ts +135 -0
- package/src/outcomes/code_review_battle.ts +135 -0
- package/src/outcomes/cold_email_battle.ts +97 -0
- package/src/outcomes/content_creation_battle.ts +160 -0
- package/src/outcomes/f1_stem_opt_compliance.ts +61 -0
- package/src/outcomes/index.ts +107 -0
- package/src/outcomes/lead_gen_battle.test.ts +113 -0
- package/src/outcomes/lead_gen_battle.ts +99 -0
- package/src/outcomes/outcome.schema.property.test.ts +229 -0
- package/src/outcomes/outcome.schema.ts +187 -0
- package/src/outcomes/qualified_sales_interest.ts +118 -0
- package/src/outcomes/swarm_planner.property.test.ts +370 -0
- package/src/outcomes/swarm_planner.ts +96 -0
- package/src/outcomes/web_extraction.ts +234 -0
- package/src/runtime/README.md +220 -0
- package/src/runtime/agentRunner.test.ts +341 -0
- package/src/runtime/agentRunner.ts +746 -0
- package/src/runtime/claudeAdapter.ts +232 -0
- package/src/runtime/costTracker.ts +123 -0
- package/src/runtime/index.ts +34 -0
- package/src/runtime/modelAdapter.property.test.ts +305 -0
- package/src/runtime/modelAdapter.ts +144 -0
- package/src/runtime/openaiAdapter.ts +235 -0
- package/src/utils/README.md +122 -0
- package/src/utils/command-runner.ts +134 -0
- package/src/utils/cost-guard.ts +379 -0
- package/src/utils/errors.test.ts +290 -0
- package/src/utils/errors.ts +442 -0
- package/src/utils/index.ts +37 -0
- package/src/utils/logger.test.ts +361 -0
- package/src/utils/logger.ts +419 -0
- package/src/utils/output-parsers.ts +216 -0
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Property-based tests for Buying Intent Validation
|
|
3
|
+
*
|
|
4
|
+
* **Feature: earnd-bounty-engine, Property 14: Buying Intent Validation**
|
|
5
|
+
* **Validates: Requirements 8.1**
|
|
6
|
+
*
|
|
7
|
+
* Property 14: Buying Intent Validation
|
|
8
|
+
* *For any* message string, the buying intent validator SHALL return true
|
|
9
|
+
* if and only if the message contains at least one of the keywords:
|
|
10
|
+
* "pricing", "demo", "next steps".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, test, expect } from 'vitest';
|
|
14
|
+
import * as fc from 'fast-check';
|
|
15
|
+
import { validateBuyingIntent, validateCompanySize, validateRole } from './validators.js';
|
|
16
|
+
|
|
17
|
+
/** The standard buying intent keywords per Requirements 8.1 */
|
|
18
|
+
const BUYING_INTENT_KEYWORDS = ['pricing', 'demo', 'next steps'];
|
|
19
|
+
|
|
20
|
+
describe('Buying Intent Validation - Property Tests', () => {
|
|
21
|
+
// **Feature: earnd-bounty-engine, Property 14: Buying Intent Validation**
|
|
22
|
+
test('messages containing at least one keyword are valid', () => {
|
|
23
|
+
fc.assert(
|
|
24
|
+
fc.property(
|
|
25
|
+
fc.string(),
|
|
26
|
+
fc.constantFrom(...BUYING_INTENT_KEYWORDS),
|
|
27
|
+
fc.string(),
|
|
28
|
+
(prefix, keyword, suffix) => {
|
|
29
|
+
// Construct a message that definitely contains the keyword
|
|
30
|
+
const message = `${prefix}${keyword}${suffix}`;
|
|
31
|
+
const result = validateBuyingIntent(message, BUYING_INTENT_KEYWORDS);
|
|
32
|
+
expect(result.valid).toBe(true);
|
|
33
|
+
expect(result.errors).toHaveLength(0);
|
|
34
|
+
}
|
|
35
|
+
),
|
|
36
|
+
{ numRuns: 100 }
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// **Feature: earnd-bounty-engine, Property 14: Buying Intent Validation**
|
|
41
|
+
test('messages without any keywords are invalid', () => {
|
|
42
|
+
// Generate strings that definitely don't contain any keywords
|
|
43
|
+
const noKeywordStringArb = fc
|
|
44
|
+
.string()
|
|
45
|
+
.filter((s) => {
|
|
46
|
+
const lower = s.toLowerCase();
|
|
47
|
+
return !BUYING_INTENT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
fc.assert(
|
|
51
|
+
fc.property(noKeywordStringArb, (message) => {
|
|
52
|
+
const result = validateBuyingIntent(message, BUYING_INTENT_KEYWORDS);
|
|
53
|
+
expect(result.valid).toBe(false);
|
|
54
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
55
|
+
}),
|
|
56
|
+
{ numRuns: 100 }
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// **Feature: earnd-bounty-engine, Property 14: Buying Intent Validation**
|
|
61
|
+
test('keyword matching is case-insensitive', () => {
|
|
62
|
+
// Generate case variations of keywords
|
|
63
|
+
const caseVariationArb = fc
|
|
64
|
+
.constantFrom(...BUYING_INTENT_KEYWORDS)
|
|
65
|
+
.chain((keyword) =>
|
|
66
|
+
fc.array(fc.boolean(), { minLength: keyword.length, maxLength: keyword.length }).map(
|
|
67
|
+
(upperFlags) =>
|
|
68
|
+
keyword
|
|
69
|
+
.split('')
|
|
70
|
+
.map((char, i) => (upperFlags[i] ? char.toUpperCase() : char.toLowerCase()))
|
|
71
|
+
.join('')
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
fc.assert(
|
|
76
|
+
fc.property(fc.string(), caseVariationArb, fc.string(), (prefix, keyword, suffix) => {
|
|
77
|
+
const message = `${prefix}${keyword}${suffix}`;
|
|
78
|
+
const result = validateBuyingIntent(message, BUYING_INTENT_KEYWORDS);
|
|
79
|
+
expect(result.valid).toBe(true);
|
|
80
|
+
expect(result.errors).toHaveLength(0);
|
|
81
|
+
}),
|
|
82
|
+
{ numRuns: 100 }
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// **Feature: earnd-bounty-engine, Property 14: Buying Intent Validation**
|
|
87
|
+
test('validation is deterministic - same input produces same output', () => {
|
|
88
|
+
fc.assert(
|
|
89
|
+
fc.property(fc.string(), (message) => {
|
|
90
|
+
const result1 = validateBuyingIntent(message, BUYING_INTENT_KEYWORDS);
|
|
91
|
+
const result2 = validateBuyingIntent(message, BUYING_INTENT_KEYWORDS);
|
|
92
|
+
expect(result1.valid).toBe(result2.valid);
|
|
93
|
+
expect(result1.errors).toEqual(result2.errors);
|
|
94
|
+
}),
|
|
95
|
+
{ numRuns: 100 }
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// **Feature: earnd-bounty-engine, Property 14: Buying Intent Validation**
|
|
100
|
+
test('empty message without keywords is invalid', () => {
|
|
101
|
+
const result = validateBuyingIntent('', BUYING_INTENT_KEYWORDS);
|
|
102
|
+
expect(result.valid).toBe(false);
|
|
103
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// **Feature: earnd-bounty-engine, Property 14: Buying Intent Validation**
|
|
107
|
+
test('validation result structure is correct', () => {
|
|
108
|
+
fc.assert(
|
|
109
|
+
fc.property(fc.string(), (message) => {
|
|
110
|
+
const result = validateBuyingIntent(message, BUYING_INTENT_KEYWORDS);
|
|
111
|
+
// Result must have valid boolean and errors array
|
|
112
|
+
expect(typeof result.valid).toBe('boolean');
|
|
113
|
+
expect(Array.isArray(result.errors)).toBe(true);
|
|
114
|
+
// If valid, errors should be empty; if invalid, errors should have content
|
|
115
|
+
if (result.valid) {
|
|
116
|
+
expect(result.errors).toHaveLength(0);
|
|
117
|
+
} else {
|
|
118
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
119
|
+
}
|
|
120
|
+
}),
|
|
121
|
+
{ numRuns: 100 }
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Property-based tests for Company Size Validation
|
|
129
|
+
*
|
|
130
|
+
* **Feature: earnd-bounty-engine, Property 15: Company Size Validation**
|
|
131
|
+
* **Validates: Requirements 8.2**
|
|
132
|
+
*
|
|
133
|
+
* Property 15: Company Size Validation
|
|
134
|
+
* *For any* company size value, the company size validator SHALL return true
|
|
135
|
+
* if and only if the value is greater than or equal to 50.
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
/** The minimum company size threshold per Requirements 8.2 */
|
|
139
|
+
const MINIMUM_COMPANY_SIZE = 50;
|
|
140
|
+
|
|
141
|
+
describe('Company Size Validation - Property Tests', () => {
|
|
142
|
+
// **Feature: earnd-bounty-engine, Property 15: Company Size Validation**
|
|
143
|
+
test('company sizes >= minimum are valid', () => {
|
|
144
|
+
fc.assert(
|
|
145
|
+
fc.property(
|
|
146
|
+
fc.integer({ min: MINIMUM_COMPANY_SIZE, max: 1000000 }),
|
|
147
|
+
(size) => {
|
|
148
|
+
const result = validateCompanySize(size, MINIMUM_COMPANY_SIZE);
|
|
149
|
+
expect(result.valid).toBe(true);
|
|
150
|
+
expect(result.errors).toHaveLength(0);
|
|
151
|
+
}
|
|
152
|
+
),
|
|
153
|
+
{ numRuns: 100 }
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// **Feature: earnd-bounty-engine, Property 15: Company Size Validation**
|
|
158
|
+
test('company sizes < minimum are invalid', () => {
|
|
159
|
+
fc.assert(
|
|
160
|
+
fc.property(
|
|
161
|
+
fc.integer({ min: 0, max: MINIMUM_COMPANY_SIZE - 1 }),
|
|
162
|
+
(size) => {
|
|
163
|
+
const result = validateCompanySize(size, MINIMUM_COMPANY_SIZE);
|
|
164
|
+
expect(result.valid).toBe(false);
|
|
165
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
166
|
+
}
|
|
167
|
+
),
|
|
168
|
+
{ numRuns: 100 }
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// **Feature: earnd-bounty-engine, Property 15: Company Size Validation**
|
|
173
|
+
test('boundary value: exactly minimum is valid', () => {
|
|
174
|
+
const result = validateCompanySize(MINIMUM_COMPANY_SIZE, MINIMUM_COMPANY_SIZE);
|
|
175
|
+
expect(result.valid).toBe(true);
|
|
176
|
+
expect(result.errors).toHaveLength(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// **Feature: earnd-bounty-engine, Property 15: Company Size Validation**
|
|
180
|
+
test('boundary value: one below minimum is invalid', () => {
|
|
181
|
+
const result = validateCompanySize(MINIMUM_COMPANY_SIZE - 1, MINIMUM_COMPANY_SIZE);
|
|
182
|
+
expect(result.valid).toBe(false);
|
|
183
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// **Feature: earnd-bounty-engine, Property 15: Company Size Validation**
|
|
187
|
+
test('validation is deterministic - same input produces same output', () => {
|
|
188
|
+
fc.assert(
|
|
189
|
+
fc.property(
|
|
190
|
+
fc.integer({ min: 0, max: 1000000 }),
|
|
191
|
+
(size) => {
|
|
192
|
+
const result1 = validateCompanySize(size, MINIMUM_COMPANY_SIZE);
|
|
193
|
+
const result2 = validateCompanySize(size, MINIMUM_COMPANY_SIZE);
|
|
194
|
+
expect(result1.valid).toBe(result2.valid);
|
|
195
|
+
expect(result1.errors).toEqual(result2.errors);
|
|
196
|
+
}
|
|
197
|
+
),
|
|
198
|
+
{ numRuns: 100 }
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// **Feature: earnd-bounty-engine, Property 15: Company Size Validation**
|
|
203
|
+
test('validation result structure is correct', () => {
|
|
204
|
+
fc.assert(
|
|
205
|
+
fc.property(
|
|
206
|
+
fc.integer({ min: 0, max: 1000000 }),
|
|
207
|
+
(size) => {
|
|
208
|
+
const result = validateCompanySize(size, MINIMUM_COMPANY_SIZE);
|
|
209
|
+
// Result must have valid boolean and errors array
|
|
210
|
+
expect(typeof result.valid).toBe('boolean');
|
|
211
|
+
expect(Array.isArray(result.errors)).toBe(true);
|
|
212
|
+
// If valid, errors should be empty; if invalid, errors should have content
|
|
213
|
+
if (result.valid) {
|
|
214
|
+
expect(result.errors).toHaveLength(0);
|
|
215
|
+
} else {
|
|
216
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
),
|
|
220
|
+
{ numRuns: 100 }
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// **Feature: earnd-bounty-engine, Property 15: Company Size Validation**
|
|
225
|
+
test('works with different minimum thresholds', () => {
|
|
226
|
+
fc.assert(
|
|
227
|
+
fc.property(
|
|
228
|
+
fc.integer({ min: 1, max: 10000 }),
|
|
229
|
+
fc.integer({ min: 0, max: 100000 }),
|
|
230
|
+
(minimum, size) => {
|
|
231
|
+
const result = validateCompanySize(size, minimum);
|
|
232
|
+
// The property: valid iff size >= minimum
|
|
233
|
+
expect(result.valid).toBe(size >= minimum);
|
|
234
|
+
}
|
|
235
|
+
),
|
|
236
|
+
{ numRuns: 100 }
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Property-based tests for Role Exclusion Validation
|
|
244
|
+
*
|
|
245
|
+
* **Feature: earnd-bounty-engine, Property 16: Role Exclusion Validation**
|
|
246
|
+
* **Validates: Requirements 8.3**
|
|
247
|
+
*
|
|
248
|
+
* Property 16: Role Exclusion Validation
|
|
249
|
+
* *For any* role string, the role validator SHALL return true
|
|
250
|
+
* if and only if the role (case-insensitive) is not "intern" or "student".
|
|
251
|
+
*/
|
|
252
|
+
|
|
253
|
+
/** The excluded roles per Requirements 8.3 */
|
|
254
|
+
const EXCLUDED_ROLES = ['intern', 'student'];
|
|
255
|
+
|
|
256
|
+
describe('Role Exclusion Validation - Property Tests', () => {
|
|
257
|
+
// **Feature: earnd-bounty-engine, Property 16: Role Exclusion Validation**
|
|
258
|
+
test('roles not in excluded list are valid', () => {
|
|
259
|
+
// Generate role strings that are not excluded
|
|
260
|
+
const validRoleArb = fc
|
|
261
|
+
.string({ minLength: 1 })
|
|
262
|
+
.filter((role) => {
|
|
263
|
+
const lowerRole = role.toLowerCase().trim();
|
|
264
|
+
return !EXCLUDED_ROLES.some((excluded) => lowerRole === excluded.toLowerCase());
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
fc.assert(
|
|
268
|
+
fc.property(validRoleArb, (role) => {
|
|
269
|
+
const result = validateRole(role, EXCLUDED_ROLES);
|
|
270
|
+
expect(result.valid).toBe(true);
|
|
271
|
+
expect(result.errors).toHaveLength(0);
|
|
272
|
+
}),
|
|
273
|
+
{ numRuns: 100 }
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// **Feature: earnd-bounty-engine, Property 16: Role Exclusion Validation**
|
|
278
|
+
test('excluded roles are invalid', () => {
|
|
279
|
+
fc.assert(
|
|
280
|
+
fc.property(
|
|
281
|
+
fc.constantFrom(...EXCLUDED_ROLES),
|
|
282
|
+
(role) => {
|
|
283
|
+
const result = validateRole(role, EXCLUDED_ROLES);
|
|
284
|
+
expect(result.valid).toBe(false);
|
|
285
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
286
|
+
}
|
|
287
|
+
),
|
|
288
|
+
{ numRuns: 100 }
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// **Feature: earnd-bounty-engine, Property 16: Role Exclusion Validation**
|
|
293
|
+
test('role matching is case-insensitive', () => {
|
|
294
|
+
// Generate case variations of excluded roles
|
|
295
|
+
const caseVariationArb = fc
|
|
296
|
+
.constantFrom(...EXCLUDED_ROLES)
|
|
297
|
+
.chain((role) =>
|
|
298
|
+
fc.array(fc.boolean(), { minLength: role.length, maxLength: role.length }).map(
|
|
299
|
+
(upperFlags) =>
|
|
300
|
+
role
|
|
301
|
+
.split('')
|
|
302
|
+
.map((char, i) => (upperFlags[i] ? char.toUpperCase() : char.toLowerCase()))
|
|
303
|
+
.join('')
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
fc.assert(
|
|
308
|
+
fc.property(caseVariationArb, (role) => {
|
|
309
|
+
const result = validateRole(role, EXCLUDED_ROLES);
|
|
310
|
+
expect(result.valid).toBe(false);
|
|
311
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
312
|
+
}),
|
|
313
|
+
{ numRuns: 100 }
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// **Feature: earnd-bounty-engine, Property 16: Role Exclusion Validation**
|
|
318
|
+
test('validation is deterministic - same input produces same output', () => {
|
|
319
|
+
fc.assert(
|
|
320
|
+
fc.property(fc.string(), (role) => {
|
|
321
|
+
const result1 = validateRole(role, EXCLUDED_ROLES);
|
|
322
|
+
const result2 = validateRole(role, EXCLUDED_ROLES);
|
|
323
|
+
expect(result1.valid).toBe(result2.valid);
|
|
324
|
+
expect(result1.errors).toEqual(result2.errors);
|
|
325
|
+
}),
|
|
326
|
+
{ numRuns: 100 }
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// **Feature: earnd-bounty-engine, Property 16: Role Exclusion Validation**
|
|
331
|
+
test('validation result structure is correct', () => {
|
|
332
|
+
fc.assert(
|
|
333
|
+
fc.property(fc.string(), (role) => {
|
|
334
|
+
const result = validateRole(role, EXCLUDED_ROLES);
|
|
335
|
+
// Result must have valid boolean and errors array
|
|
336
|
+
expect(typeof result.valid).toBe('boolean');
|
|
337
|
+
expect(Array.isArray(result.errors)).toBe(true);
|
|
338
|
+
// If valid, errors should be empty; if invalid, errors should have content
|
|
339
|
+
if (result.valid) {
|
|
340
|
+
expect(result.errors).toHaveLength(0);
|
|
341
|
+
} else {
|
|
342
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
343
|
+
}
|
|
344
|
+
}),
|
|
345
|
+
{ numRuns: 100 }
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// **Feature: earnd-bounty-engine, Property 16: Role Exclusion Validation**
|
|
350
|
+
test('common valid roles pass validation', () => {
|
|
351
|
+
const validRoles = [
|
|
352
|
+
'Engineering Manager',
|
|
353
|
+
'Software Engineer',
|
|
354
|
+
'CEO',
|
|
355
|
+
'CTO',
|
|
356
|
+
'Product Manager',
|
|
357
|
+
'Director',
|
|
358
|
+
'VP of Sales',
|
|
359
|
+
'Founder',
|
|
360
|
+
];
|
|
361
|
+
|
|
362
|
+
fc.assert(
|
|
363
|
+
fc.property(fc.constantFrom(...validRoles), (role) => {
|
|
364
|
+
const result = validateRole(role, EXCLUDED_ROLES);
|
|
365
|
+
expect(result.valid).toBe(true);
|
|
366
|
+
expect(result.errors).toHaveLength(0);
|
|
367
|
+
}),
|
|
368
|
+
{ numRuns: 100 }
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// **Feature: earnd-bounty-engine, Property 16: Role Exclusion Validation**
|
|
373
|
+
test('roles containing excluded words as substrings are valid', () => {
|
|
374
|
+
// Roles that contain "intern" or "student" as substrings but are not exact matches
|
|
375
|
+
const substringRoles = [
|
|
376
|
+
'Internal Affairs',
|
|
377
|
+
'International Sales',
|
|
378
|
+
'Student Affairs Director',
|
|
379
|
+
'Internship Coordinator',
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
fc.assert(
|
|
383
|
+
fc.property(fc.constantFrom(...substringRoles), (role) => {
|
|
384
|
+
const result = validateRole(role, EXCLUDED_ROLES);
|
|
385
|
+
expect(result.valid).toBe(true);
|
|
386
|
+
expect(result.errors).toHaveLength(0);
|
|
387
|
+
}),
|
|
388
|
+
{ numRuns: 100 }
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// **Feature: earnd-bounty-engine, Property 16: Role Exclusion Validation**
|
|
393
|
+
test('whitespace-trimmed excluded roles are invalid', () => {
|
|
394
|
+
// Excluded roles with leading/trailing whitespace
|
|
395
|
+
const paddedExcludedRoles = [
|
|
396
|
+
' intern',
|
|
397
|
+
'intern ',
|
|
398
|
+
' intern ',
|
|
399
|
+
' student',
|
|
400
|
+
'student ',
|
|
401
|
+
' student ',
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
fc.assert(
|
|
405
|
+
fc.property(fc.constantFrom(...paddedExcludedRoles), (role) => {
|
|
406
|
+
const result = validateRole(role, EXCLUDED_ROLES);
|
|
407
|
+
expect(result.valid).toBe(false);
|
|
408
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
409
|
+
}),
|
|
410
|
+
{ numRuns: 100 }
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Property-based tests for Message Length Validation
|
|
418
|
+
*
|
|
419
|
+
* **Feature: earnd-bounty-engine, Property 17: Message Length Validation**
|
|
420
|
+
* **Validates: Requirements 8.4**
|
|
421
|
+
*
|
|
422
|
+
* Property 17: Message Length Validation
|
|
423
|
+
* *For any* message string, the message length validator SHALL return true
|
|
424
|
+
* if and only if the word count is greater than or equal to 20.
|
|
425
|
+
*/
|
|
426
|
+
|
|
427
|
+
import { validateMessageLength } from './validators.js';
|
|
428
|
+
|
|
429
|
+
/** The minimum word count per Requirements 8.4 */
|
|
430
|
+
const MINIMUM_WORD_COUNT = 20;
|
|
431
|
+
|
|
432
|
+
describe('Message Length Validation - Property Tests', () => {
|
|
433
|
+
// **Feature: earnd-bounty-engine, Property 17: Message Length Validation**
|
|
434
|
+
test('messages with >= minimum words are valid', () => {
|
|
435
|
+
// Generate arrays of words with at least MINIMUM_WORD_COUNT words
|
|
436
|
+
const validMessageArb = fc
|
|
437
|
+
.array(fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0 && !/\s/.test(s)), {
|
|
438
|
+
minLength: MINIMUM_WORD_COUNT,
|
|
439
|
+
maxLength: 100,
|
|
440
|
+
})
|
|
441
|
+
.map((words) => words.join(' '));
|
|
442
|
+
|
|
443
|
+
fc.assert(
|
|
444
|
+
fc.property(validMessageArb, (message) => {
|
|
445
|
+
const result = validateMessageLength(message, MINIMUM_WORD_COUNT);
|
|
446
|
+
expect(result.valid).toBe(true);
|
|
447
|
+
expect(result.errors).toHaveLength(0);
|
|
448
|
+
}),
|
|
449
|
+
{ numRuns: 100 }
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// **Feature: earnd-bounty-engine, Property 17: Message Length Validation**
|
|
454
|
+
test('messages with < minimum words are invalid', () => {
|
|
455
|
+
// Generate arrays of words with fewer than MINIMUM_WORD_COUNT words
|
|
456
|
+
const shortMessageArb = fc
|
|
457
|
+
.array(fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0 && !/\s/.test(s)), {
|
|
458
|
+
minLength: 0,
|
|
459
|
+
maxLength: MINIMUM_WORD_COUNT - 1,
|
|
460
|
+
})
|
|
461
|
+
.map((words) => words.join(' '));
|
|
462
|
+
|
|
463
|
+
fc.assert(
|
|
464
|
+
fc.property(shortMessageArb, (message) => {
|
|
465
|
+
const result = validateMessageLength(message, MINIMUM_WORD_COUNT);
|
|
466
|
+
expect(result.valid).toBe(false);
|
|
467
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
468
|
+
}),
|
|
469
|
+
{ numRuns: 100 }
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// **Feature: earnd-bounty-engine, Property 17: Message Length Validation**
|
|
474
|
+
test('boundary value: exactly minimum words is valid', () => {
|
|
475
|
+
const words = Array(MINIMUM_WORD_COUNT).fill('word');
|
|
476
|
+
const message = words.join(' ');
|
|
477
|
+
const result = validateMessageLength(message, MINIMUM_WORD_COUNT);
|
|
478
|
+
expect(result.valid).toBe(true);
|
|
479
|
+
expect(result.errors).toHaveLength(0);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// **Feature: earnd-bounty-engine, Property 17: Message Length Validation**
|
|
483
|
+
test('boundary value: one below minimum words is invalid', () => {
|
|
484
|
+
const words = Array(MINIMUM_WORD_COUNT - 1).fill('word');
|
|
485
|
+
const message = words.join(' ');
|
|
486
|
+
const result = validateMessageLength(message, MINIMUM_WORD_COUNT);
|
|
487
|
+
expect(result.valid).toBe(false);
|
|
488
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// **Feature: earnd-bounty-engine, Property 17: Message Length Validation**
|
|
492
|
+
test('validation is deterministic - same input produces same output', () => {
|
|
493
|
+
fc.assert(
|
|
494
|
+
fc.property(fc.string(), (message) => {
|
|
495
|
+
const result1 = validateMessageLength(message, MINIMUM_WORD_COUNT);
|
|
496
|
+
const result2 = validateMessageLength(message, MINIMUM_WORD_COUNT);
|
|
497
|
+
expect(result1.valid).toBe(result2.valid);
|
|
498
|
+
expect(result1.errors).toEqual(result2.errors);
|
|
499
|
+
}),
|
|
500
|
+
{ numRuns: 100 }
|
|
501
|
+
);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// **Feature: earnd-bounty-engine, Property 17: Message Length Validation**
|
|
505
|
+
test('validation result structure is correct', () => {
|
|
506
|
+
fc.assert(
|
|
507
|
+
fc.property(fc.string(), (message) => {
|
|
508
|
+
const result = validateMessageLength(message, MINIMUM_WORD_COUNT);
|
|
509
|
+
// Result must have valid boolean and errors array
|
|
510
|
+
expect(typeof result.valid).toBe('boolean');
|
|
511
|
+
expect(Array.isArray(result.errors)).toBe(true);
|
|
512
|
+
// If valid, errors should be empty; if invalid, errors should have content
|
|
513
|
+
if (result.valid) {
|
|
514
|
+
expect(result.errors).toHaveLength(0);
|
|
515
|
+
} else {
|
|
516
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
517
|
+
}
|
|
518
|
+
}),
|
|
519
|
+
{ numRuns: 100 }
|
|
520
|
+
);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// **Feature: earnd-bounty-engine, Property 17: Message Length Validation**
|
|
524
|
+
test('empty message is invalid', () => {
|
|
525
|
+
const result = validateMessageLength('', MINIMUM_WORD_COUNT);
|
|
526
|
+
expect(result.valid).toBe(false);
|
|
527
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// **Feature: earnd-bounty-engine, Property 17: Message Length Validation**
|
|
531
|
+
test('whitespace-only message is invalid', () => {
|
|
532
|
+
fc.assert(
|
|
533
|
+
fc.property(
|
|
534
|
+
fc.array(fc.constantFrom(' ', '\t', '\n', '\r'), { minLength: 1, maxLength: 50 }).map(
|
|
535
|
+
(chars) => chars.join('')
|
|
536
|
+
),
|
|
537
|
+
(whitespace) => {
|
|
538
|
+
const result = validateMessageLength(whitespace, MINIMUM_WORD_COUNT);
|
|
539
|
+
expect(result.valid).toBe(false);
|
|
540
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
541
|
+
}
|
|
542
|
+
),
|
|
543
|
+
{ numRuns: 100 }
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// **Feature: earnd-bounty-engine, Property 17: Message Length Validation**
|
|
548
|
+
test('works with different minimum thresholds', () => {
|
|
549
|
+
fc.assert(
|
|
550
|
+
fc.property(
|
|
551
|
+
fc.integer({ min: 1, max: 50 }),
|
|
552
|
+
fc.array(fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0 && !/\s/.test(s)), {
|
|
553
|
+
minLength: 0,
|
|
554
|
+
maxLength: 100,
|
|
555
|
+
}),
|
|
556
|
+
(minWords, words) => {
|
|
557
|
+
const message = words.join(' ');
|
|
558
|
+
const result = validateMessageLength(message, minWords);
|
|
559
|
+
// The property: valid iff word count >= minWords
|
|
560
|
+
expect(result.valid).toBe(words.length >= minWords);
|
|
561
|
+
}
|
|
562
|
+
),
|
|
563
|
+
{ numRuns: 100 }
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Property-based tests for Email Syntax Validation
|
|
571
|
+
*
|
|
572
|
+
* **Feature: earnd-bounty-engine, Property 18: Email Syntax Validation**
|
|
573
|
+
* **Validates: Requirements 8.5**
|
|
574
|
+
*
|
|
575
|
+
* Property 18: Email Syntax Validation
|
|
576
|
+
* *For any* email string, the email validator SHALL return true
|
|
577
|
+
* if and only if the string matches a valid email syntax pattern.
|
|
578
|
+
*/
|
|
579
|
+
|
|
580
|
+
import { validateEmail } from './validators.js';
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Custom arbitrary for emails matching the validator's regex pattern:
|
|
584
|
+
* /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
|
|
585
|
+
*/
|
|
586
|
+
const validEmailArb = fc
|
|
587
|
+
.tuple(
|
|
588
|
+
// Local part: alphanumeric, dots, underscores, percent, plus, hyphens
|
|
589
|
+
fc.stringMatching(/^[a-zA-Z0-9._%+-]+$/).filter((s) => s.length > 0),
|
|
590
|
+
// Domain: alphanumeric and hyphens with dots
|
|
591
|
+
fc.stringMatching(/^[a-zA-Z0-9-]+$/).filter((s) => s.length > 0),
|
|
592
|
+
// TLD: at least 2 alphabetic characters
|
|
593
|
+
fc.stringMatching(/^[a-zA-Z]{2,6}$/)
|
|
594
|
+
)
|
|
595
|
+
.map(([local, domain, tld]) => `${local}@${domain}.${tld}`);
|
|
596
|
+
|
|
597
|
+
describe('Email Syntax Validation - Property Tests', () => {
|
|
598
|
+
// **Feature: earnd-bounty-engine, Property 18: Email Syntax Validation**
|
|
599
|
+
test('valid email addresses pass validation', () => {
|
|
600
|
+
fc.assert(
|
|
601
|
+
fc.property(validEmailArb, (email) => {
|
|
602
|
+
const result = validateEmail(email);
|
|
603
|
+
expect(result.valid).toBe(true);
|
|
604
|
+
expect(result.errors).toHaveLength(0);
|
|
605
|
+
}),
|
|
606
|
+
{ numRuns: 100 }
|
|
607
|
+
);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// **Feature: earnd-bounty-engine, Property 18: Email Syntax Validation**
|
|
611
|
+
test('strings without @ symbol are invalid', () => {
|
|
612
|
+
const noAtSymbolArb = fc.string().filter((s) => !s.includes('@'));
|
|
613
|
+
|
|
614
|
+
fc.assert(
|
|
615
|
+
fc.property(noAtSymbolArb, (email) => {
|
|
616
|
+
const result = validateEmail(email);
|
|
617
|
+
expect(result.valid).toBe(false);
|
|
618
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
619
|
+
}),
|
|
620
|
+
{ numRuns: 100 }
|
|
621
|
+
);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// **Feature: earnd-bounty-engine, Property 18: Email Syntax Validation**
|
|
625
|
+
test('strings with multiple @ symbols are invalid', () => {
|
|
626
|
+
// Generate strings with at least 2 @ symbols
|
|
627
|
+
const multipleAtArb = fc
|
|
628
|
+
.tuple(fc.string(), fc.string(), fc.string())
|
|
629
|
+
.map(([a, b, c]) => `${a}@${b}@${c}`);
|
|
630
|
+
|
|
631
|
+
fc.assert(
|
|
632
|
+
fc.property(multipleAtArb, (email) => {
|
|
633
|
+
const result = validateEmail(email);
|
|
634
|
+
expect(result.valid).toBe(false);
|
|
635
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
636
|
+
}),
|
|
637
|
+
{ numRuns: 100 }
|
|
638
|
+
);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// **Feature: earnd-bounty-engine, Property 18: Email Syntax Validation**
|
|
642
|
+
test('strings without domain TLD are invalid', () => {
|
|
643
|
+
// Generate strings with @ but no dot in domain
|
|
644
|
+
const noDotDomainArb = fc
|
|
645
|
+
.tuple(
|
|
646
|
+
fc.stringMatching(/^[a-zA-Z0-9._%+-]+$/),
|
|
647
|
+
fc.stringMatching(/^[a-zA-Z0-9-]+$/)
|
|
648
|
+
)
|
|
649
|
+
.filter(([local, domain]) => local.length > 0 && domain.length > 0)
|
|
650
|
+
.map(([local, domain]) => `${local}@${domain}`);
|
|
651
|
+
|
|
652
|
+
fc.assert(
|
|
653
|
+
fc.property(noDotDomainArb, (email) => {
|
|
654
|
+
const result = validateEmail(email);
|
|
655
|
+
expect(result.valid).toBe(false);
|
|
656
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
657
|
+
}),
|
|
658
|
+
{ numRuns: 100 }
|
|
659
|
+
);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// **Feature: earnd-bounty-engine, Property 18: Email Syntax Validation**
|
|
663
|
+
test('validation is deterministic - same input produces same output', () => {
|
|
664
|
+
fc.assert(
|
|
665
|
+
fc.property(fc.string(), (email) => {
|
|
666
|
+
const result1 = validateEmail(email);
|
|
667
|
+
const result2 = validateEmail(email);
|
|
668
|
+
expect(result1.valid).toBe(result2.valid);
|
|
669
|
+
expect(result1.errors).toEqual(result2.errors);
|
|
670
|
+
}),
|
|
671
|
+
{ numRuns: 100 }
|
|
672
|
+
);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// **Feature: earnd-bounty-engine, Property 18: Email Syntax Validation**
|
|
676
|
+
test('validation result structure is correct', () => {
|
|
677
|
+
fc.assert(
|
|
678
|
+
fc.property(fc.string(), (email) => {
|
|
679
|
+
const result = validateEmail(email);
|
|
680
|
+
// Result must have valid boolean and errors array
|
|
681
|
+
expect(typeof result.valid).toBe('boolean');
|
|
682
|
+
expect(Array.isArray(result.errors)).toBe(true);
|
|
683
|
+
// If valid, errors should be empty; if invalid, errors should have content
|
|
684
|
+
if (result.valid) {
|
|
685
|
+
expect(result.errors).toHaveLength(0);
|
|
686
|
+
} else {
|
|
687
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
688
|
+
}
|
|
689
|
+
}),
|
|
690
|
+
{ numRuns: 100 }
|
|
691
|
+
);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
// **Feature: earnd-bounty-engine, Property 18: Email Syntax Validation**
|
|
695
|
+
test('empty string is invalid', () => {
|
|
696
|
+
const result = validateEmail('');
|
|
697
|
+
expect(result.valid).toBe(false);
|
|
698
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// **Feature: earnd-bounty-engine, Property 18: Email Syntax Validation**
|
|
702
|
+
test('common valid email formats pass validation', () => {
|
|
703
|
+
const validEmails = [
|
|
704
|
+
'user@example.com',
|
|
705
|
+
'user.name@example.com',
|
|
706
|
+
'user+tag@example.com',
|
|
707
|
+
'user_name@example.co.uk',
|
|
708
|
+
'user123@subdomain.example.org',
|
|
709
|
+
'first.last@company.io',
|
|
710
|
+
];
|
|
711
|
+
|
|
712
|
+
fc.assert(
|
|
713
|
+
fc.property(fc.constantFrom(...validEmails), (email) => {
|
|
714
|
+
const result = validateEmail(email);
|
|
715
|
+
expect(result.valid).toBe(true);
|
|
716
|
+
expect(result.errors).toHaveLength(0);
|
|
717
|
+
}),
|
|
718
|
+
{ numRuns: 100 }
|
|
719
|
+
);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
// **Feature: earnd-bounty-engine, Property 18: Email Syntax Validation**
|
|
723
|
+
test('common invalid email formats fail validation', () => {
|
|
724
|
+
const invalidEmails = [
|
|
725
|
+
'plainaddress',
|
|
726
|
+
'@missinglocal.com',
|
|
727
|
+
'missing@.com',
|
|
728
|
+
'missing@domain.',
|
|
729
|
+
'spaces in@email.com',
|
|
730
|
+
'email@domain',
|
|
731
|
+
];
|
|
732
|
+
|
|
733
|
+
fc.assert(
|
|
734
|
+
fc.property(fc.constantFrom(...invalidEmails), (email) => {
|
|
735
|
+
const result = validateEmail(email);
|
|
736
|
+
expect(result.valid).toBe(false);
|
|
737
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
738
|
+
}),
|
|
739
|
+
{ numRuns: 100 }
|
|
740
|
+
);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// **Feature: earnd-bounty-engine, Property 18: Email Syntax Validation**
|
|
744
|
+
test('TLD must be at least 2 characters', () => {
|
|
745
|
+
// Generate emails with single-character TLDs
|
|
746
|
+
const shortTldArb = fc
|
|
747
|
+
.tuple(
|
|
748
|
+
fc.stringMatching(/^[a-zA-Z0-9._%+-]+$/).filter((s) => s.length > 0),
|
|
749
|
+
fc.stringMatching(/^[a-zA-Z0-9-]+$/).filter((s) => s.length > 0),
|
|
750
|
+
fc.stringMatching(/^[a-zA-Z]$/)
|
|
751
|
+
)
|
|
752
|
+
.map(([local, domain, tld]) => `${local}@${domain}.${tld}`);
|
|
753
|
+
|
|
754
|
+
fc.assert(
|
|
755
|
+
fc.property(shortTldArb, (email) => {
|
|
756
|
+
const result = validateEmail(email);
|
|
757
|
+
expect(result.valid).toBe(false);
|
|
758
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
759
|
+
}),
|
|
760
|
+
{ numRuns: 100 }
|
|
761
|
+
);
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Property-based tests for LinkedIn URL Validation
|
|
767
|
+
*
|
|
768
|
+
* **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
769
|
+
* **Validates: Requirements 1.1**
|
|
770
|
+
*
|
|
771
|
+
* Property 1: Tournament validator completeness
|
|
772
|
+
* *For any* LinkedIn URL string, the LinkedIn validator SHALL return true
|
|
773
|
+
* if and only if the URL starts with "https://www.linkedin.com/in/".
|
|
774
|
+
*/
|
|
775
|
+
|
|
776
|
+
import { validateLinkedIn, validateLeadGenPrecision } from './validators.js';
|
|
777
|
+
|
|
778
|
+
/** The required LinkedIn URL prefix per Requirements 2.7, 4.6 */
|
|
779
|
+
const LINKEDIN_PREFIX = 'https://www.linkedin.com/in/';
|
|
780
|
+
|
|
781
|
+
describe('LinkedIn URL Validation - Property Tests', () => {
|
|
782
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
783
|
+
test('URLs with correct prefix are valid', () => {
|
|
784
|
+
// Generate LinkedIn URLs with the correct prefix
|
|
785
|
+
const validLinkedInArb = fc
|
|
786
|
+
.string({ minLength: 1 })
|
|
787
|
+
.map((suffix) => `${LINKEDIN_PREFIX}${suffix}`);
|
|
788
|
+
|
|
789
|
+
fc.assert(
|
|
790
|
+
fc.property(validLinkedInArb, (linkedIn) => {
|
|
791
|
+
const result = validateLinkedIn(linkedIn);
|
|
792
|
+
expect(result.valid).toBe(true);
|
|
793
|
+
expect(result.errors).toHaveLength(0);
|
|
794
|
+
}),
|
|
795
|
+
{ numRuns: 100 }
|
|
796
|
+
);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
800
|
+
test('URLs without correct prefix are invalid', () => {
|
|
801
|
+
// Generate URLs that don't start with the correct prefix
|
|
802
|
+
const invalidLinkedInArb = fc
|
|
803
|
+
.string()
|
|
804
|
+
.filter((url) => !url.startsWith(LINKEDIN_PREFIX));
|
|
805
|
+
|
|
806
|
+
fc.assert(
|
|
807
|
+
fc.property(invalidLinkedInArb, (linkedIn) => {
|
|
808
|
+
const result = validateLinkedIn(linkedIn);
|
|
809
|
+
expect(result.valid).toBe(false);
|
|
810
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
811
|
+
expect(result.errors[0]).toContain('Invalid LinkedIn URL');
|
|
812
|
+
}),
|
|
813
|
+
{ numRuns: 100 }
|
|
814
|
+
);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
818
|
+
test('validation is deterministic - same input produces same output', () => {
|
|
819
|
+
fc.assert(
|
|
820
|
+
fc.property(fc.string(), (linkedIn) => {
|
|
821
|
+
const result1 = validateLinkedIn(linkedIn);
|
|
822
|
+
const result2 = validateLinkedIn(linkedIn);
|
|
823
|
+
expect(result1.valid).toBe(result2.valid);
|
|
824
|
+
expect(result1.errors).toEqual(result2.errors);
|
|
825
|
+
}),
|
|
826
|
+
{ numRuns: 100 }
|
|
827
|
+
);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
831
|
+
test('validation result structure is correct', () => {
|
|
832
|
+
fc.assert(
|
|
833
|
+
fc.property(fc.string(), (linkedIn) => {
|
|
834
|
+
const result = validateLinkedIn(linkedIn);
|
|
835
|
+
// Result must have valid boolean and errors array
|
|
836
|
+
expect(typeof result.valid).toBe('boolean');
|
|
837
|
+
expect(Array.isArray(result.errors)).toBe(true);
|
|
838
|
+
// If valid, errors should be empty; if invalid, errors should have content
|
|
839
|
+
if (result.valid) {
|
|
840
|
+
expect(result.errors).toHaveLength(0);
|
|
841
|
+
} else {
|
|
842
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
843
|
+
}
|
|
844
|
+
}),
|
|
845
|
+
{ numRuns: 100 }
|
|
846
|
+
);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
850
|
+
test('empty string is invalid', () => {
|
|
851
|
+
const result = validateLinkedIn('');
|
|
852
|
+
expect(result.valid).toBe(false);
|
|
853
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
857
|
+
test('common valid LinkedIn URLs pass validation', () => {
|
|
858
|
+
const validLinkedInUrls = [
|
|
859
|
+
'https://www.linkedin.com/in/johndoe',
|
|
860
|
+
'https://www.linkedin.com/in/jane-smith-123',
|
|
861
|
+
'https://www.linkedin.com/in/user_name',
|
|
862
|
+
'https://www.linkedin.com/in/first-last-456789',
|
|
863
|
+
];
|
|
864
|
+
|
|
865
|
+
fc.assert(
|
|
866
|
+
fc.property(fc.constantFrom(...validLinkedInUrls), (linkedIn) => {
|
|
867
|
+
const result = validateLinkedIn(linkedIn);
|
|
868
|
+
expect(result.valid).toBe(true);
|
|
869
|
+
expect(result.errors).toHaveLength(0);
|
|
870
|
+
}),
|
|
871
|
+
{ numRuns: 100 }
|
|
872
|
+
);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
876
|
+
test('common invalid LinkedIn URLs fail validation', () => {
|
|
877
|
+
const invalidLinkedInUrls = [
|
|
878
|
+
'http://www.linkedin.com/in/johndoe', // http instead of https
|
|
879
|
+
'https://linkedin.com/in/johndoe', // missing www
|
|
880
|
+
'https://www.linkedin.com/johndoe', // missing /in/
|
|
881
|
+
'https://www.facebook.com/johndoe', // wrong domain
|
|
882
|
+
'www.linkedin.com/in/johndoe', // missing protocol
|
|
883
|
+
];
|
|
884
|
+
|
|
885
|
+
fc.assert(
|
|
886
|
+
fc.property(fc.constantFrom(...invalidLinkedInUrls), (linkedIn) => {
|
|
887
|
+
const result = validateLinkedIn(linkedIn);
|
|
888
|
+
expect(result.valid).toBe(false);
|
|
889
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
890
|
+
}),
|
|
891
|
+
{ numRuns: 100 }
|
|
892
|
+
);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
896
|
+
test('prefix matching is case-sensitive', () => {
|
|
897
|
+
// Generate case variations of the prefix (should all be invalid except exact match)
|
|
898
|
+
const caseVariations = [
|
|
899
|
+
'HTTPS://WWW.LINKEDIN.COM/IN/',
|
|
900
|
+
'https://WWW.linkedin.com/in/',
|
|
901
|
+
'https://www.LINKEDIN.com/in/',
|
|
902
|
+
'https://www.linkedin.COM/in/',
|
|
903
|
+
'https://www.linkedin.com/IN/',
|
|
904
|
+
];
|
|
905
|
+
|
|
906
|
+
fc.assert(
|
|
907
|
+
fc.property(fc.constantFrom(...caseVariations), fc.string(), (prefix, suffix) => {
|
|
908
|
+
const linkedIn = `${prefix}${suffix}`;
|
|
909
|
+
const result = validateLinkedIn(linkedIn);
|
|
910
|
+
// Only the exact prefix should be valid
|
|
911
|
+
if (prefix === LINKEDIN_PREFIX) {
|
|
912
|
+
expect(result.valid).toBe(true);
|
|
913
|
+
} else {
|
|
914
|
+
expect(result.valid).toBe(false);
|
|
915
|
+
}
|
|
916
|
+
}),
|
|
917
|
+
{ numRuns: 100 }
|
|
918
|
+
);
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Property-based tests for Lead Generation Precision Validation
|
|
925
|
+
*
|
|
926
|
+
* **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
927
|
+
* **Validates: Requirements 1.1**
|
|
928
|
+
*
|
|
929
|
+
* Property 1: Tournament validator completeness (continued)
|
|
930
|
+
* *For any* lead generation artifact, the composite validator SHALL return true
|
|
931
|
+
* if and only if ALL criteria are met: valid email, company size >= 50,
|
|
932
|
+
* non-excluded role, and valid LinkedIn URL.
|
|
933
|
+
*/
|
|
934
|
+
|
|
935
|
+
/** Lead generation artifact arbitrary for property testing */
|
|
936
|
+
const leadGenArtifactArb = fc.record({
|
|
937
|
+
email: fc.emailAddress(),
|
|
938
|
+
companySize: fc.integer({ min: 1, max: 10000 }),
|
|
939
|
+
role: fc.string({ minLength: 1, maxLength: 50 }),
|
|
940
|
+
linkedIn: fc.string({ minLength: 1, maxLength: 100 }),
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
/** Valid lead generation artifact arbitrary */
|
|
944
|
+
const validLeadGenArtifactArb = fc.record({
|
|
945
|
+
email: fc.emailAddress(),
|
|
946
|
+
companySize: fc.integer({ min: 50, max: 10000 }),
|
|
947
|
+
role: fc.string({ minLength: 1, maxLength: 50 }).filter((role) => {
|
|
948
|
+
const lowerRole = role.toLowerCase().trim();
|
|
949
|
+
return !['intern', 'student'].includes(lowerRole);
|
|
950
|
+
}),
|
|
951
|
+
linkedIn: fc.string({ minLength: 1 }).map((suffix) => `${LINKEDIN_PREFIX}${suffix}`),
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
describe('Lead Generation Precision Validation - Property Tests', () => {
|
|
955
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
956
|
+
test('artifacts meeting all criteria are valid', () => {
|
|
957
|
+
fc.assert(
|
|
958
|
+
fc.property(validLeadGenArtifactArb, (artifact) => {
|
|
959
|
+
const result = validateLeadGenPrecision(artifact);
|
|
960
|
+
expect(result.valid).toBe(true);
|
|
961
|
+
expect(result.errors).toHaveLength(0);
|
|
962
|
+
}),
|
|
963
|
+
{ numRuns: 100 }
|
|
964
|
+
);
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
968
|
+
test('artifacts with invalid email are invalid', () => {
|
|
969
|
+
const invalidEmailArtifactArb = validLeadGenArtifactArb.map((artifact) => ({
|
|
970
|
+
...artifact,
|
|
971
|
+
email: 'invalid-email-format', // Invalid email
|
|
972
|
+
}));
|
|
973
|
+
|
|
974
|
+
fc.assert(
|
|
975
|
+
fc.property(invalidEmailArtifactArb, (artifact) => {
|
|
976
|
+
const result = validateLeadGenPrecision(artifact);
|
|
977
|
+
expect(result.valid).toBe(false);
|
|
978
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
979
|
+
expect(result.errors.some((error) => error.includes('Invalid email format'))).toBe(true);
|
|
980
|
+
}),
|
|
981
|
+
{ numRuns: 100 }
|
|
982
|
+
);
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
986
|
+
test('artifacts with company size < 50 are invalid', () => {
|
|
987
|
+
const smallCompanyArtifactArb = validLeadGenArtifactArb.map((artifact) => ({
|
|
988
|
+
...artifact,
|
|
989
|
+
companySize: fc.sample(fc.integer({ min: 1, max: 49 }), 1)[0], // Company size < 50
|
|
990
|
+
}));
|
|
991
|
+
|
|
992
|
+
fc.assert(
|
|
993
|
+
fc.property(smallCompanyArtifactArb, (artifact) => {
|
|
994
|
+
const result = validateLeadGenPrecision(artifact);
|
|
995
|
+
expect(result.valid).toBe(false);
|
|
996
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
997
|
+
expect(result.errors.some((error) => error.includes('Company too small'))).toBe(true);
|
|
998
|
+
}),
|
|
999
|
+
{ numRuns: 100 }
|
|
1000
|
+
);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
1004
|
+
test('artifacts with excluded roles are invalid', () => {
|
|
1005
|
+
const excludedRoleArtifactArb = validLeadGenArtifactArb.map((artifact) => ({
|
|
1006
|
+
...artifact,
|
|
1007
|
+
role: fc.sample(fc.constantFrom('intern', 'student', 'INTERN', 'Student'), 1)[0], // Excluded role
|
|
1008
|
+
}));
|
|
1009
|
+
|
|
1010
|
+
fc.assert(
|
|
1011
|
+
fc.property(excludedRoleArtifactArb, (artifact) => {
|
|
1012
|
+
const result = validateLeadGenPrecision(artifact);
|
|
1013
|
+
expect(result.valid).toBe(false);
|
|
1014
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
1015
|
+
expect(result.errors.some((error) => error.includes('Invalid role'))).toBe(true);
|
|
1016
|
+
}),
|
|
1017
|
+
{ numRuns: 100 }
|
|
1018
|
+
);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
1022
|
+
test('artifacts with invalid LinkedIn URL are invalid', () => {
|
|
1023
|
+
const invalidLinkedInArtifactArb = validLeadGenArtifactArb.map((artifact) => ({
|
|
1024
|
+
...artifact,
|
|
1025
|
+
linkedIn: 'https://www.facebook.com/profile', // Invalid LinkedIn URL
|
|
1026
|
+
}));
|
|
1027
|
+
|
|
1028
|
+
fc.assert(
|
|
1029
|
+
fc.property(invalidLinkedInArtifactArb, (artifact) => {
|
|
1030
|
+
const result = validateLeadGenPrecision(artifact);
|
|
1031
|
+
expect(result.valid).toBe(false);
|
|
1032
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
1033
|
+
expect(result.errors.some((error) => error.includes('Invalid LinkedIn URL'))).toBe(true);
|
|
1034
|
+
}),
|
|
1035
|
+
{ numRuns: 100 }
|
|
1036
|
+
);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
1040
|
+
test('validation is deterministic - same input produces same output', () => {
|
|
1041
|
+
fc.assert(
|
|
1042
|
+
fc.property(leadGenArtifactArb, (artifact) => {
|
|
1043
|
+
const result1 = validateLeadGenPrecision(artifact);
|
|
1044
|
+
const result2 = validateLeadGenPrecision(artifact);
|
|
1045
|
+
expect(result1.valid).toBe(result2.valid);
|
|
1046
|
+
expect(result1.errors).toEqual(result2.errors);
|
|
1047
|
+
}),
|
|
1048
|
+
{ numRuns: 100 }
|
|
1049
|
+
);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
1053
|
+
test('validation result structure is correct', () => {
|
|
1054
|
+
fc.assert(
|
|
1055
|
+
fc.property(leadGenArtifactArb, (artifact) => {
|
|
1056
|
+
const result = validateLeadGenPrecision(artifact);
|
|
1057
|
+
// Result must have valid boolean and errors array
|
|
1058
|
+
expect(typeof result.valid).toBe('boolean');
|
|
1059
|
+
expect(Array.isArray(result.errors)).toBe(true);
|
|
1060
|
+
// If valid, errors should be empty; if invalid, errors should have content
|
|
1061
|
+
if (result.valid) {
|
|
1062
|
+
expect(result.errors).toHaveLength(0);
|
|
1063
|
+
} else {
|
|
1064
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
1065
|
+
}
|
|
1066
|
+
}),
|
|
1067
|
+
{ numRuns: 100 }
|
|
1068
|
+
);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
1072
|
+
test('multiple validation errors are accumulated', () => {
|
|
1073
|
+
// Create an artifact that fails all validations
|
|
1074
|
+
const allInvalidArtifact = {
|
|
1075
|
+
email: 'invalid-email',
|
|
1076
|
+
companySize: 10, // Too small
|
|
1077
|
+
role: 'intern', // Excluded role
|
|
1078
|
+
linkedIn: 'https://www.facebook.com/profile', // Invalid LinkedIn
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
const result = validateLeadGenPrecision(allInvalidArtifact);
|
|
1082
|
+
expect(result.valid).toBe(false);
|
|
1083
|
+
expect(result.errors.length).toBe(4); // Should have 4 errors (one for each validation)
|
|
1084
|
+
expect(result.errors.some((error) => error.includes('Invalid email format'))).toBe(true);
|
|
1085
|
+
expect(result.errors.some((error) => error.includes('Company too small'))).toBe(true);
|
|
1086
|
+
expect(result.errors.some((error) => error.includes('Invalid role'))).toBe(true);
|
|
1087
|
+
expect(result.errors.some((error) => error.includes('Invalid LinkedIn URL'))).toBe(true);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
1091
|
+
test('boundary value: company size exactly 50 is valid', () => {
|
|
1092
|
+
const boundaryArtifact = {
|
|
1093
|
+
email: 'user@example.com',
|
|
1094
|
+
companySize: 50, // Exactly at boundary
|
|
1095
|
+
role: 'Manager',
|
|
1096
|
+
linkedIn: 'https://www.linkedin.com/in/user',
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
const result = validateLeadGenPrecision(boundaryArtifact);
|
|
1100
|
+
expect(result.valid).toBe(true);
|
|
1101
|
+
expect(result.errors).toHaveLength(0);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
// **Feature: launch-readiness-checklist, Property 1: Tournament validator completeness**
|
|
1105
|
+
test('boundary value: company size 49 is invalid', () => {
|
|
1106
|
+
const boundaryArtifact = {
|
|
1107
|
+
email: 'user@example.com',
|
|
1108
|
+
companySize: 49, // Just below boundary
|
|
1109
|
+
role: 'Manager',
|
|
1110
|
+
linkedIn: 'https://www.linkedin.com/in/user',
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
const result = validateLeadGenPrecision(boundaryArtifact);
|
|
1114
|
+
expect(result.valid).toBe(false);
|
|
1115
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
1116
|
+
expect(result.errors.some((error) => error.includes('Company too small'))).toBe(true);
|
|
1117
|
+
});
|
|
1118
|
+
});
|