@coldiq/mcp 0.2.5 → 0.2.7
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/registry.d.ts.map +1 -1
- package/dist/registry.js +17 -1
- package/dist/registry.js.map +1 -1
- package/dist/tools/find-signals.d.ts +2 -0
- package/dist/tools/find-signals.d.ts.map +1 -1
- package/dist/tools/find-signals.js +37 -4
- package/dist/tools/find-signals.js.map +1 -1
- package/dist/tools/search-places.d.ts +6 -0
- package/dist/tools/search-places.d.ts.map +1 -1
- package/dist/tools/search-places.js +48 -4
- package/dist/tools/search-places.js.map +1 -1
- package/dist/tools/verify-email.d.ts +2 -1
- package/dist/tools/verify-email.d.ts.map +1 -1
- package/dist/tools/verify-email.js +57 -1
- package/dist/tools/verify-email.js.map +1 -1
- package/package.json +1 -1
- package/src/registry.ts +17 -1
- package/src/tools/find-signals.ts +36 -4
- package/src/tools/search-places.ts +45 -4
- package/src/tools/verify-email.ts +65 -1
- package/tests/registry-enrich-person.test.ts +30 -2
- package/tests/registry-find-signals.test.ts +31 -0
- package/tests/tools/find-signals.test.ts +62 -0
- package/tests/tools/search-places.test.ts +97 -1
- package/tests/tools/verify-email.test.ts +77 -3
|
@@ -92,8 +92,36 @@ describe('apollo-people-match', () => {
|
|
|
92
92
|
})
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
-
it('hasResult: true when person
|
|
96
|
-
expect(p().hasResult({ person: { name: 'Michel Lieben', id: 'abc' } })).toBe(true)
|
|
95
|
+
it('hasResult: true when person has linkedin_url', () => {
|
|
96
|
+
expect(p().hasResult({ person: { name: 'Michel Lieben', id: 'abc', linkedin_url: 'https://www.linkedin.com/in/michel-lieben' } })).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('hasResult: true when person has email', () => {
|
|
100
|
+
expect(p().hasResult({ person: { name: 'Michel Lieben', email: 'michel@coldiq.com' } })).toBe(true)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('hasResult: true when person has non-empty employment_history', () => {
|
|
104
|
+
expect(p().hasResult({ person: { name: 'Michel Lieben', employment_history: [{ organization_id: 'org_1' }] } })).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('hasResult: false on Apollo silent-empty record (name only, all other fields null)', () => {
|
|
108
|
+
// Apollo /people/match returns 200 with this shape when no real match is found.
|
|
109
|
+
// The executor must fall through to the next provider instead of surfacing junk.
|
|
110
|
+
expect(p().hasResult({
|
|
111
|
+
person: {
|
|
112
|
+
id: '6a0c19edfe0e7300102a02dc',
|
|
113
|
+
first_name: 'Alex',
|
|
114
|
+
last_name: 'Vacca',
|
|
115
|
+
name: 'Alex Vacca',
|
|
116
|
+
linkedin_url: null,
|
|
117
|
+
title: null,
|
|
118
|
+
organization_id: null,
|
|
119
|
+
employment_history: [],
|
|
120
|
+
email: null,
|
|
121
|
+
headline: null,
|
|
122
|
+
seniority: null,
|
|
123
|
+
},
|
|
124
|
+
})).toBe(false)
|
|
97
125
|
})
|
|
98
126
|
|
|
99
127
|
it('hasResult: false when person is null', () => {
|
|
@@ -60,6 +60,18 @@ describe('signalbase-funding', () => {
|
|
|
60
60
|
expect((result.queryParams as Record<string, string>).limit).toBe('100')
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
+
it('mapParams forwards round_type as comma-joined `round` upstream param', () => {
|
|
64
|
+
const result = p().mapParams({ signal_type: 'funding', round_type: ['Series A', 'Seed'], limit: 10 })
|
|
65
|
+
expect((result.queryParams as Record<string, string>).round).toBe('Series A,Seed')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('mapParams omits `round` when round_type is missing or empty', () => {
|
|
69
|
+
const noRound = p().mapParams({ signal_type: 'funding', limit: 10 })
|
|
70
|
+
expect((noRound.queryParams as Record<string, string>).round).toBeUndefined()
|
|
71
|
+
const emptyRound = p().mapParams({ signal_type: 'funding', round_type: [], limit: 10 })
|
|
72
|
+
expect((emptyRound.queryParams as Record<string, string>).round).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
|
|
63
75
|
it('hasResult: true when data array non-empty', () => {
|
|
64
76
|
expect(p().hasResult({ data: [{ company: 'ColdIQ', amount: 1000000 }] })).toBe(true)
|
|
65
77
|
})
|
|
@@ -335,6 +347,25 @@ describe('theirstack-buying-intents', () => {
|
|
|
335
347
|
const body = result.body as Record<string, unknown>
|
|
336
348
|
expect(body.company_name_or).toBeUndefined()
|
|
337
349
|
expect(body.company_domain).toBeUndefined()
|
|
350
|
+
expect(body.keyword_slug_or).toBeUndefined()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('mapParams forwards topics as keyword_slug_or upstream param', () => {
|
|
354
|
+
const result = p().mapParams({
|
|
355
|
+
signal_type: 'intent',
|
|
356
|
+
companies: ['ColdIQ'],
|
|
357
|
+
topics: ['sales-automation', 'lead-generation'],
|
|
358
|
+
})
|
|
359
|
+
const body = result.body as Record<string, unknown>
|
|
360
|
+
expect(body.keyword_slug_or).toEqual(['sales-automation', 'lead-generation'])
|
|
361
|
+
// topics is supplemental — company filter still forwarded.
|
|
362
|
+
expect(body.company_name_or).toEqual(['ColdIQ'])
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('mapParams omits keyword_slug_or when topics is empty', () => {
|
|
366
|
+
const result = p().mapParams({ signal_type: 'intent', companies: ['ColdIQ'], topics: [] })
|
|
367
|
+
const body = result.body as Record<string, unknown>
|
|
368
|
+
expect(body.keyword_slug_or).toBeUndefined()
|
|
338
369
|
})
|
|
339
370
|
|
|
340
371
|
it('hasResult: true when data non-empty', () => {
|
|
@@ -108,3 +108,65 @@ describe('find_signals handler — company-targeted types pass through', () => {
|
|
|
108
108
|
})
|
|
109
109
|
}
|
|
110
110
|
})
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// hiring industries client-side post-filter
|
|
114
|
+
//
|
|
115
|
+
// Signalbase /hiring-signals has no upstream `industry` query param, so the
|
|
116
|
+
// handler filters the result rows by industries instead of silently dropping
|
|
117
|
+
// the filter.
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
import { executeWithFallback as mockedExecute } from '../../src/executor.js'
|
|
121
|
+
|
|
122
|
+
describe('find_signals handler — hiring industries post-filter', () => {
|
|
123
|
+
const hiringRows = [
|
|
124
|
+
{ id: 'a', title: 'Sales Lead', companyName: 'CreatePay', industries: 'Financial Services' },
|
|
125
|
+
{ id: 'b', title: 'Sales Assistant', companyName: 'AstraZeneca', industries: 'Pharmaceutical Manufacturing' },
|
|
126
|
+
{ id: 'c', title: 'Account Executive', companyName: 'Monex Europe', industries: 'Financial Services and Foreign Exchange' },
|
|
127
|
+
{ id: 'd', title: 'Legal Counsel', companyName: 'Eversheds', industries: 'Law Practice and Legal Services' },
|
|
128
|
+
{ id: 'e', title: 'Engineer', companyName: 'NoIndustries', industries: '' },
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
it('filters rows by case-insensitive industries substring match', async () => {
|
|
132
|
+
vi.mocked(mockedExecute).mockResolvedValueOnce({
|
|
133
|
+
data: { success: true, data: [...hiringRows] },
|
|
134
|
+
_meta: { provider: 'signalbase-hiring', latencyMs: 1 },
|
|
135
|
+
})
|
|
136
|
+
const result = await findSignalsHandler({ signal_type: 'hiring', industries: ['financial services'], limit: 25 })
|
|
137
|
+
const body = JSON.parse(result.content[0].text)
|
|
138
|
+
expect(body.data.data).toHaveLength(2)
|
|
139
|
+
expect(body.data.data.map((r: { id: string }) => r.id)).toEqual(['a', 'c'])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('returns empty array when no row matches any industry', async () => {
|
|
143
|
+
vi.mocked(mockedExecute).mockResolvedValueOnce({
|
|
144
|
+
data: { success: true, data: [...hiringRows] },
|
|
145
|
+
_meta: { provider: 'signalbase-hiring', latencyMs: 1 },
|
|
146
|
+
})
|
|
147
|
+
const result = await findSignalsHandler({ signal_type: 'hiring', industries: ['Aerospace'], limit: 25 })
|
|
148
|
+
const body = JSON.parse(result.content[0].text)
|
|
149
|
+
expect(body.data.data).toEqual([])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('leaves rows untouched when industries is absent', async () => {
|
|
153
|
+
vi.mocked(mockedExecute).mockResolvedValueOnce({
|
|
154
|
+
data: { success: true, data: [...hiringRows] },
|
|
155
|
+
_meta: { provider: 'signalbase-hiring', latencyMs: 1 },
|
|
156
|
+
})
|
|
157
|
+
const result = await findSignalsHandler({ signal_type: 'hiring', limit: 25 })
|
|
158
|
+
const body = JSON.parse(result.content[0].text)
|
|
159
|
+
expect(body.data.data).toHaveLength(5)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('does not apply filter to non-hiring signal types', async () => {
|
|
163
|
+
vi.mocked(mockedExecute).mockResolvedValueOnce({
|
|
164
|
+
data: { success: true, data: [...hiringRows] },
|
|
165
|
+
_meta: { provider: 'signalbase-funding', latencyMs: 1 },
|
|
166
|
+
})
|
|
167
|
+
const result = await findSignalsHandler({ signal_type: 'funding', industries: ['Aerospace'], limit: 25 })
|
|
168
|
+
const body = JSON.parse(result.content[0].text)
|
|
169
|
+
// Untouched: industries is forwarded upstream for funding, not post-filtered here.
|
|
170
|
+
expect(body.data.data).toHaveLength(5)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { initClient } from '../../src/client.js'
|
|
3
|
-
import { searchPlacesHandler } from '../../src/tools/search-places.js'
|
|
3
|
+
import { searchPlacesHandler, applyPlaceFilters } from '../../src/tools/search-places.js'
|
|
4
|
+
|
|
5
|
+
describe('applyPlaceFilters', () => {
|
|
6
|
+
const sample = {
|
|
7
|
+
places: [
|
|
8
|
+
{ title: 'Low rating, many reviews', totalScore: 2.9, reviewsCount: 81 },
|
|
9
|
+
{ title: 'Mid rating, mid reviews', totalScore: 3.9, reviewsCount: 322 },
|
|
10
|
+
{ title: 'High rating, many reviews', totalScore: 4.6, reviewsCount: 1101 },
|
|
11
|
+
{ title: 'High rating, few reviews', totalScore: 4.7, reviewsCount: 12 },
|
|
12
|
+
{ title: 'Missing fields', /* no totalScore or reviewsCount */ },
|
|
13
|
+
],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
it('passes through unchanged when no filters supplied', () => {
|
|
17
|
+
const out = applyPlaceFilters(sample, {}) as { places: unknown[] }
|
|
18
|
+
expect(out.places).toHaveLength(5)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('does not mutate its input', () => {
|
|
22
|
+
const before = JSON.stringify(sample)
|
|
23
|
+
applyPlaceFilters(sample, { maxRating: 4 })
|
|
24
|
+
expect(JSON.stringify(sample)).toBe(before)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('max_overall_rating drops places above the cap', () => {
|
|
28
|
+
const out = applyPlaceFilters(sample, { maxRating: 4 }) as { places: Array<{ totalScore?: number }> }
|
|
29
|
+
expect(out.places.map((p) => p.totalScore)).toEqual([2.9, 3.9])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('min_total_reviews drops places below the floor', () => {
|
|
33
|
+
const out = applyPlaceFilters(sample, { minReviews: 20 }) as { places: Array<{ reviewsCount?: number }> }
|
|
34
|
+
expect(out.places.map((p) => p.reviewsCount)).toEqual([81, 322, 1101])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('combined filters apply as AND', () => {
|
|
38
|
+
const out = applyPlaceFilters(sample, { maxRating: 4, minReviews: 20 }) as { places: Array<{ title?: string }> }
|
|
39
|
+
expect(out.places.map((p) => p.title)).toEqual(['Low rating, many reviews', 'Mid rating, mid reviews'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('places with missing totalScore/reviewsCount are dropped when corresponding filter is active', () => {
|
|
43
|
+
const out = applyPlaceFilters(sample, { minRating: 0 }) as { places: Array<{ title?: string }> }
|
|
44
|
+
expect(out.places.map((p) => p.title)).not.toContain('Missing fields')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('non-object / non-array shapes are passed through untouched', () => {
|
|
48
|
+
expect(applyPlaceFilters(null, { maxRating: 4 })).toBe(null)
|
|
49
|
+
expect(applyPlaceFilters({ places: 'not an array' }, { maxRating: 4 })).toEqual({ places: 'not an array' })
|
|
50
|
+
})
|
|
51
|
+
})
|
|
4
52
|
|
|
5
53
|
describe('search_places handler', () => {
|
|
6
54
|
const originalFetch = globalThis.fetch
|
|
@@ -100,6 +148,54 @@ describe('search_places handler', () => {
|
|
|
100
148
|
}
|
|
101
149
|
})
|
|
102
150
|
|
|
151
|
+
it('google_maps response is post-filtered by max_overall_rating + min_total_reviews', async () => {
|
|
152
|
+
// Regression guard: if the handler stops wiring applyPlaceFilters, this test fails.
|
|
153
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
154
|
+
const u = url.toString()
|
|
155
|
+
if (u.includes('/google-maps/scraper') && !u.includes('/scraper/')) {
|
|
156
|
+
return new Response(JSON.stringify({ jobId: 'gm-filter' }), { status: 200 })
|
|
157
|
+
}
|
|
158
|
+
if (u.includes('/google-maps/scraper/gm-filter')) {
|
|
159
|
+
return new Response(JSON.stringify({
|
|
160
|
+
jobId: 'gm-filter',
|
|
161
|
+
status: 'done',
|
|
162
|
+
places: [
|
|
163
|
+
{ title: 'Strict match — keep', totalScore: 3.5, reviewsCount: 120 },
|
|
164
|
+
{ title: 'Rating too high — drop', totalScore: 4.7, reviewsCount: 800 },
|
|
165
|
+
{ title: 'Too few reviews — drop', totalScore: 2.9, reviewsCount: 10 },
|
|
166
|
+
],
|
|
167
|
+
}), { status: 200 })
|
|
168
|
+
}
|
|
169
|
+
return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
|
|
170
|
+
}) as typeof fetch
|
|
171
|
+
|
|
172
|
+
const { getProviders } = await import('../../src/registry.js')
|
|
173
|
+
const providers = getProviders('search_places')
|
|
174
|
+
const gm = providers.find((p) => p.id === 'google_maps')!
|
|
175
|
+
const orig = { t: gm.async!.timeoutMs, i: gm.async!.pollIntervalMs }
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
gm.async!.timeoutMs = 500
|
|
179
|
+
gm.async!.pollIntervalMs = 20
|
|
180
|
+
const result = await searchPlacesHandler({
|
|
181
|
+
query: 'dentist',
|
|
182
|
+
country: 'FR',
|
|
183
|
+
provider: 'google_maps',
|
|
184
|
+
max_overall_rating: 4,
|
|
185
|
+
min_total_reviews: 20,
|
|
186
|
+
limit: 10,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
expect(result.isError).toBeFalsy()
|
|
190
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
191
|
+
expect(parsed._meta.provider).toBe('google_maps')
|
|
192
|
+
expect(parsed.data.places.map((p: { title: string }) => p.title)).toEqual(['Strict match — keep'])
|
|
193
|
+
} finally {
|
|
194
|
+
gm.async!.timeoutMs = orig.t
|
|
195
|
+
gm.async!.pollIntervalMs = orig.i
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
103
199
|
it('all providers fail — returns isError', async () => {
|
|
104
200
|
globalThis.fetch = vi.fn(async () => {
|
|
105
201
|
return new Response(JSON.stringify({ error: 'down' }), { status: 503 })
|
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { initClient } from '../../src/client.js'
|
|
3
|
-
import { verifyEmailHandler } from '../../src/tools/verify-email.js'
|
|
3
|
+
import { verifyEmailHandler, addNormalizedStatus } from '../../src/tools/verify-email.js'
|
|
4
|
+
|
|
5
|
+
describe('addNormalizedStatus', () => {
|
|
6
|
+
it('findymail verified=true → status valid', () => {
|
|
7
|
+
const out = addNormalizedStatus({ email: 'michel@coldiq.com', verified: true, provider: 'Google' }, 'findymail') as Record<string, unknown>
|
|
8
|
+
expect(out.status).toBe('valid')
|
|
9
|
+
// Original fields preserved for back-compat.
|
|
10
|
+
expect(out.verified).toBe(true)
|
|
11
|
+
expect(out.provider).toBe('Google')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('findymail verified=false → status invalid', () => {
|
|
15
|
+
const out = addNormalizedStatus({ email: 'bad@example.com', verified: false, provider: 'Other' }, 'findymail') as Record<string, unknown>
|
|
16
|
+
expect(out.status).toBe('invalid')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('icypeas item.status=catch_all → catch_all', () => {
|
|
20
|
+
const out = addNormalizedStatus({ success: true, item: { status: 'catch_all', email: 'x@y.com' } }, 'icypeas') as Record<string, unknown>
|
|
21
|
+
expect(out.status).toBe('catch_all')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('icypeas missing item → unknown', () => {
|
|
25
|
+
const out = addNormalizedStatus({ success: true }, 'icypeas') as Record<string, unknown>
|
|
26
|
+
expect(out.status).toBe('unknown')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('upstream status="catchall" is normalized to catch_all', () => {
|
|
30
|
+
const out = addNormalizedStatus({ status: 'catchall', email: 'x@y.com' }, 'instantly') as Record<string, unknown>
|
|
31
|
+
expect(out.status).toBe('catch_all')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('upstream status="risky" stays risky', () => {
|
|
35
|
+
const out = addNormalizedStatus({ status: 'risky' }, 'instantly') as Record<string, unknown>
|
|
36
|
+
expect(out.status).toBe('risky')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('upstream status="disposable" stays disposable', () => {
|
|
40
|
+
const out = addNormalizedStatus({ status: 'disposable' }, 'instantly') as Record<string, unknown>
|
|
41
|
+
expect(out.status).toBe('disposable')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('upstream status="VALID" is lowercased to valid', () => {
|
|
45
|
+
const out = addNormalizedStatus({ status: 'VALID' }, 'instantly') as Record<string, unknown>
|
|
46
|
+
expect(out.status).toBe('valid')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('unknown provider id with no signal fields → unknown', () => {
|
|
50
|
+
const out = addNormalizedStatus({ foo: 'bar' }, 'mystery-provider') as Record<string, unknown>
|
|
51
|
+
expect(out.status).toBe('unknown')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('null / non-object data is returned unchanged', () => {
|
|
55
|
+
expect(addNormalizedStatus(null, 'findymail')).toBe(null)
|
|
56
|
+
expect(addNormalizedStatus('a string', 'findymail')).toBe('a string')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('linkupapi-validate verified=true → valid', () => {
|
|
60
|
+
const out = addNormalizedStatus({ verified: true }, 'linkupapi-validate') as Record<string, unknown>
|
|
61
|
+
expect(out.status).toBe('valid')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
4
64
|
|
|
5
65
|
describe('verify_email handler', () => {
|
|
6
66
|
const originalFetch = globalThis.fetch
|
|
@@ -13,9 +73,9 @@ describe('verify_email handler', () => {
|
|
|
13
73
|
globalThis.fetch = originalFetch
|
|
14
74
|
})
|
|
15
75
|
|
|
16
|
-
it('
|
|
76
|
+
it('normalizes findymail real upstream shape (verified boolean) to status=valid', async () => {
|
|
17
77
|
globalThis.fetch = vi.fn(async () =>
|
|
18
|
-
new Response(JSON.stringify({
|
|
78
|
+
new Response(JSON.stringify({ email: 'michel@coldiq.com', verified: true, provider: 'Google' }), { status: 200 })
|
|
19
79
|
) as typeof fetch
|
|
20
80
|
|
|
21
81
|
const result = await verifyEmailHandler({ email: 'michel@coldiq.com' })
|
|
@@ -23,6 +83,20 @@ describe('verify_email handler', () => {
|
|
|
23
83
|
const parsed = JSON.parse(result.content[0].text)
|
|
24
84
|
expect(parsed._meta.provider).toBe('findymail')
|
|
25
85
|
expect(parsed.data.status).toBe('valid')
|
|
86
|
+
// Raw fields still surfaced for callers that already depend on them.
|
|
87
|
+
expect(parsed.data.verified).toBe(true)
|
|
88
|
+
expect(parsed.data.provider).toBe('Google')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('normalizes findymail verified=false to status=invalid', async () => {
|
|
92
|
+
globalThis.fetch = vi.fn(async () =>
|
|
93
|
+
new Response(JSON.stringify({ email: 'bad@example.com', verified: false, provider: 'Other' }), { status: 200 })
|
|
94
|
+
) as typeof fetch
|
|
95
|
+
|
|
96
|
+
const result = await verifyEmailHandler({ email: 'bad@example.com' })
|
|
97
|
+
|
|
98
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
99
|
+
expect(parsed.data.status).toBe('invalid')
|
|
26
100
|
})
|
|
27
101
|
|
|
28
102
|
it('falls back to IcyPeas on FindyMail failure', async () => {
|