@elevasis/core 0.18.0 → 0.20.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/dist/index.d.ts +82 -1
- package/dist/index.js +353 -171
- package/dist/knowledge/index.d.ts +44 -1
- package/dist/organization-model/index.d.ts +82 -1
- package/dist/organization-model/index.js +353 -171
- package/dist/test-utils/index.d.ts +41 -12
- package/dist/test-utils/index.js +352 -171
- package/package.json +4 -3
- package/src/_gen/__tests__/__snapshots__/contracts.md.snap +89 -69
- package/src/auth/multi-tenancy/organizations/__tests__/api-schemas.test.ts +194 -0
- package/src/auth/multi-tenancy/organizations/api-schemas.ts +136 -128
- package/src/business/acquisition/api-schemas.test.ts +199 -15
- package/src/business/acquisition/api-schemas.ts +116 -51
- package/src/business/acquisition/build-templates.test.ts +212 -0
- package/src/business/acquisition/derive-actions.test.ts +1 -1
- package/src/business/acquisition/types.ts +21 -38
- package/src/business/deals/api-schemas.ts +2 -2
- package/src/execution/engine/index.ts +436 -434
- package/src/execution/engine/tools/integration/server/adapters/google-calendar/google-calendar-adapter.ts +428 -0
- package/src/execution/engine/tools/integration/server/adapters/google-calendar/index.ts +2 -0
- package/src/execution/engine/tools/lead-service-types.ts +51 -9
- package/src/execution/engine/tools/platform/acquisition/company-tools.ts +7 -6
- package/src/execution/engine/tools/platform/acquisition/contact-tools.ts +6 -5
- package/src/execution/engine/tools/platform/acquisition/types.ts +20 -9
- package/src/execution/engine/tools/registry.ts +700 -698
- package/src/execution/engine/tools/tool-maps.ts +10 -0
- package/src/execution/external/__tests__/api-schemas.test.ts +127 -0
- package/src/integrations/oauth/__tests__/provider-registry.test.ts +7 -6
- package/src/integrations/oauth/provider-registry.ts +74 -61
- package/src/integrations/oauth/server/credentials.ts +43 -39
- package/src/knowledge/__tests__/queries.test.ts +89 -0
- package/src/organization-model/__tests__/graph.test.ts +108 -2
- package/src/organization-model/__tests__/icons.test.ts +61 -0
- package/src/organization-model/__tests__/knowledge.test.ts +118 -1
- package/src/organization-model/__tests__/prospecting-ssot.test.ts +91 -0
- package/src/organization-model/__tests__/schema.test.ts +122 -0
- package/src/organization-model/__tests__/surface-projection.test.ts +174 -0
- package/src/organization-model/defaults.ts +8 -0
- package/src/organization-model/domains/knowledge.ts +9 -0
- package/src/organization-model/domains/prospecting.ts +347 -226
- package/src/organization-model/domains/sales.ts +40 -30
- package/src/organization-model/graph/build.ts +74 -0
- package/src/organization-model/graph/schema.ts +1 -0
- package/src/organization-model/graph/types.ts +1 -0
- package/src/organization-model/icons.ts +3 -0
- package/src/organization-model/schema.ts +63 -0
- package/src/organization-model/surface-projection.ts +218 -0
- package/src/organization-model/types.ts +9 -1
- package/src/platform/constants/versions.ts +1 -1
- package/src/platform/utils/__tests__/validation.test.ts +1084 -1083
- package/src/platform/utils/validation.ts +425 -425
- package/src/reference/_generated/contracts.md +89 -69
- package/src/server.ts +6 -0
- package/src/supabase/database.types.ts +6 -12
|
@@ -258,6 +258,8 @@ import type {
|
|
|
258
258
|
RecordListExecutionParams,
|
|
259
259
|
UpdateCompanyStageParams,
|
|
260
260
|
UpdateContactStageParams,
|
|
261
|
+
ListPendingCompanyIdsParams,
|
|
262
|
+
ListPendingContactIdsParams,
|
|
261
263
|
UpsertSocialPostParams,
|
|
262
264
|
UpsertSocialPostsResult,
|
|
263
265
|
GetDealByIdParams,
|
|
@@ -725,6 +727,14 @@ export type ListToolMap = {
|
|
|
725
727
|
params: Omit<UpdateContactStageParams, 'organizationId'>
|
|
726
728
|
result: void
|
|
727
729
|
}
|
|
730
|
+
listPendingCompanyIds: {
|
|
731
|
+
params: Omit<ListPendingCompanyIdsParams, 'organizationId'>
|
|
732
|
+
result: string[]
|
|
733
|
+
}
|
|
734
|
+
listPendingContactIds: {
|
|
735
|
+
params: Omit<ListPendingContactIdsParams, 'organizationId'>
|
|
736
|
+
result: string[]
|
|
737
|
+
}
|
|
728
738
|
}
|
|
729
739
|
|
|
730
740
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { ExecuteAsyncEnvelopeSchema, GetExecutionsQuerySchema } from '../api-schemas'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// ExecuteAsyncEnvelopeSchema
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
describe('ExecuteAsyncEnvelopeSchema', () => {
|
|
9
|
+
it('accepts a minimal payload with only resourceId', () => {
|
|
10
|
+
const result = ExecuteAsyncEnvelopeSchema.safeParse({ resourceId: 'my-workflow' })
|
|
11
|
+
expect(result.success).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('accepts resourceId with optional input passthrough', () => {
|
|
15
|
+
const result = ExecuteAsyncEnvelopeSchema.safeParse({
|
|
16
|
+
resourceId: 'my-workflow',
|
|
17
|
+
input: { foo: 'bar', nested: { count: 5 } }
|
|
18
|
+
})
|
|
19
|
+
expect(result.success).toBe(true)
|
|
20
|
+
if (result.success) {
|
|
21
|
+
expect(result.data.resourceId).toBe('my-workflow')
|
|
22
|
+
expect(result.data.input).toEqual({ foo: 'bar', nested: { count: 5 } })
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('accepts any arbitrary input shape (unknown passthrough)', () => {
|
|
27
|
+
const result = ExecuteAsyncEnvelopeSchema.safeParse({
|
|
28
|
+
resourceId: 'some-resource',
|
|
29
|
+
input: [1, 2, 3]
|
|
30
|
+
})
|
|
31
|
+
expect(result.success).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('accepts input as null (unknown type allows null)', () => {
|
|
35
|
+
const result = ExecuteAsyncEnvelopeSchema.safeParse({ resourceId: 'r', input: null })
|
|
36
|
+
expect(result.success).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('accepts payload without input field (input is optional)', () => {
|
|
40
|
+
const result = ExecuteAsyncEnvelopeSchema.safeParse({ resourceId: 'r' })
|
|
41
|
+
expect(result.success).toBe(true)
|
|
42
|
+
if (result.success) {
|
|
43
|
+
expect(result.data.input).toBeUndefined()
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('rejects an empty resourceId (minLength 1)', () => {
|
|
48
|
+
expect(ExecuteAsyncEnvelopeSchema.safeParse({ resourceId: '' }).success).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('rejects a missing resourceId', () => {
|
|
52
|
+
expect(ExecuteAsyncEnvelopeSchema.safeParse({}).success).toBe(false)
|
|
53
|
+
expect(ExecuteAsyncEnvelopeSchema.safeParse({ input: { x: 1 } }).success).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('rejects resourceId as a non-string type', () => {
|
|
57
|
+
expect(ExecuteAsyncEnvelopeSchema.safeParse({ resourceId: 42 }).success).toBe(false)
|
|
58
|
+
expect(ExecuteAsyncEnvelopeSchema.safeParse({ resourceId: null }).success).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// GetExecutionsQuerySchema
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
describe('GetExecutionsQuerySchema', () => {
|
|
67
|
+
it('accepts an empty query and applies default limit of 50', () => {
|
|
68
|
+
const result = GetExecutionsQuerySchema.safeParse({})
|
|
69
|
+
expect(result.success).toBe(true)
|
|
70
|
+
if (result.success) {
|
|
71
|
+
expect(result.data.limit).toBe(50)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('coerces limit from string "10" to number 10', () => {
|
|
76
|
+
const result = GetExecutionsQuerySchema.safeParse({ limit: '10' })
|
|
77
|
+
expect(result.success).toBe(true)
|
|
78
|
+
if (result.success) expect(result.data.limit).toBe(10)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('coerces limit from string "100" to number 100 (upper boundary)', () => {
|
|
82
|
+
const result = GetExecutionsQuerySchema.safeParse({ limit: '100' })
|
|
83
|
+
expect(result.success).toBe(true)
|
|
84
|
+
if (result.success) expect(result.data.limit).toBe(100)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('coerces limit from string "1" to number 1 (lower boundary)', () => {
|
|
88
|
+
const result = GetExecutionsQuerySchema.safeParse({ limit: '1' })
|
|
89
|
+
expect(result.success).toBe(true)
|
|
90
|
+
if (result.success) expect(result.data.limit).toBe(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('rejects limit of 101 (above max 100)', () => {
|
|
94
|
+
expect(GetExecutionsQuerySchema.safeParse({ limit: '101' }).success).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('rejects limit of 0 (below min 1)', () => {
|
|
98
|
+
expect(GetExecutionsQuerySchema.safeParse({ limit: '0' }).success).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('rejects a negative limit', () => {
|
|
102
|
+
expect(GetExecutionsQuerySchema.safeParse({ limit: '-1' }).success).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('rejects a non-numeric string for limit', () => {
|
|
106
|
+
expect(GetExecutionsQuerySchema.safeParse({ limit: 'abc' }).success).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('accepts status as an optional string filter', () => {
|
|
110
|
+
const result = GetExecutionsQuerySchema.safeParse({ status: 'running' })
|
|
111
|
+
expect(result.success).toBe(true)
|
|
112
|
+
if (result.success) expect(result.data.status).toBe('running')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('accepts status as undefined (optional field omitted)', () => {
|
|
116
|
+
const result = GetExecutionsQuerySchema.safeParse({})
|
|
117
|
+
expect(result.success).toBe(true)
|
|
118
|
+
if (result.success) expect(result.data.status).toBeUndefined()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('note: GetExecutionsQuerySchema does not have an offset field', () => {
|
|
122
|
+
// The schema only defines limit and status -- no offset field exists.
|
|
123
|
+
// Passing offset is neither rejected nor coerced (schema is not strict).
|
|
124
|
+
const result = GetExecutionsQuerySchema.safeParse({ limit: '10', offset: '5' })
|
|
125
|
+
expect(result.success).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -38,12 +38,13 @@ describe('OAuth Provider Registry', () => {
|
|
|
38
38
|
})
|
|
39
39
|
|
|
40
40
|
describe('listProviders', () => {
|
|
41
|
-
it('should return all providers as array', () => {
|
|
42
|
-
const providers = listProviders()
|
|
43
|
-
expect(providers).toHaveLength(
|
|
44
|
-
expect(providers).toContainEqual(OAUTH_PROVIDERS['google-sheets'])
|
|
45
|
-
expect(providers).toContainEqual(OAUTH_PROVIDERS
|
|
46
|
-
|
|
41
|
+
it('should return all providers as array', () => {
|
|
42
|
+
const providers = listProviders()
|
|
43
|
+
expect(providers).toHaveLength(Object.keys(OAUTH_PROVIDERS).length)
|
|
44
|
+
expect(providers).toContainEqual(OAUTH_PROVIDERS['google-sheets'])
|
|
45
|
+
expect(providers).toContainEqual(OAUTH_PROVIDERS['google-calendar'])
|
|
46
|
+
expect(providers).toContainEqual(OAUTH_PROVIDERS.dropbox)
|
|
47
|
+
})
|
|
47
48
|
|
|
48
49
|
it('should return array with correct provider structure', () => {
|
|
49
50
|
const providers = listProviders()
|
|
@@ -1,61 +1,74 @@
|
|
|
1
|
-
import type { OAuthProviderConfig } from './types'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* OAuth Provider Registry
|
|
5
|
-
*
|
|
6
|
-
* Add new integration = add config object here (no new code needed)
|
|
7
|
-
*
|
|
8
|
-
* Standard token exchange methods:
|
|
9
|
-
* - basic-auth: client_id:client_secret in Authorization header
|
|
10
|
-
* - form-encoded: credentials in URL-encoded body (Google)
|
|
11
|
-
* - json-body: credentials in JSON body (most providers)
|
|
12
|
-
*
|
|
13
|
-
* Custom overrides for edge cases:
|
|
14
|
-
* - customAuthFlow: Override authorization URL building
|
|
15
|
-
* - customTokenExchange: Override token exchange logic
|
|
16
|
-
*/
|
|
17
|
-
export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
|
18
|
-
'google-sheets': {
|
|
19
|
-
id: 'google-sheets',
|
|
20
|
-
name: 'Google Sheets',
|
|
21
|
-
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
22
|
-
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
23
|
-
authParams: {
|
|
24
|
-
access_type: 'offline', // Required for refresh token
|
|
25
|
-
prompt: 'consent' // Force consent to get refresh token on reconnect
|
|
26
|
-
},
|
|
27
|
-
tokenExchange: 'form-encoded',
|
|
28
|
-
scopes: ['https://www.googleapis.com/auth/spreadsheets']
|
|
29
|
-
},
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
id: '
|
|
33
|
-
name: '
|
|
34
|
-
authUrl: 'https://
|
|
35
|
-
tokenUrl: 'https://
|
|
36
|
-
authParams: {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
1
|
+
import type { OAuthProviderConfig } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OAuth Provider Registry
|
|
5
|
+
*
|
|
6
|
+
* Add new integration = add config object here (no new code needed)
|
|
7
|
+
*
|
|
8
|
+
* Standard token exchange methods:
|
|
9
|
+
* - basic-auth: client_id:client_secret in Authorization header
|
|
10
|
+
* - form-encoded: credentials in URL-encoded body (Google)
|
|
11
|
+
* - json-body: credentials in JSON body (most providers)
|
|
12
|
+
*
|
|
13
|
+
* Custom overrides for edge cases:
|
|
14
|
+
* - customAuthFlow: Override authorization URL building
|
|
15
|
+
* - customTokenExchange: Override token exchange logic
|
|
16
|
+
*/
|
|
17
|
+
export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
|
18
|
+
'google-sheets': {
|
|
19
|
+
id: 'google-sheets',
|
|
20
|
+
name: 'Google Sheets',
|
|
21
|
+
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
22
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
23
|
+
authParams: {
|
|
24
|
+
access_type: 'offline', // Required for refresh token
|
|
25
|
+
prompt: 'consent' // Force consent to get refresh token on reconnect
|
|
26
|
+
},
|
|
27
|
+
tokenExchange: 'form-encoded',
|
|
28
|
+
scopes: ['https://www.googleapis.com/auth/spreadsheets']
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
'google-calendar': {
|
|
32
|
+
id: 'google-calendar',
|
|
33
|
+
name: 'Google Calendar',
|
|
34
|
+
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
35
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
36
|
+
authParams: {
|
|
37
|
+
access_type: 'offline', // Required for refresh token
|
|
38
|
+
prompt: 'consent' // Force consent to get refresh token on reconnect
|
|
39
|
+
},
|
|
40
|
+
tokenExchange: 'form-encoded',
|
|
41
|
+
scopes: ['https://www.googleapis.com/auth/calendar.readonly']
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
dropbox: {
|
|
45
|
+
id: 'dropbox',
|
|
46
|
+
name: 'Dropbox',
|
|
47
|
+
authUrl: 'https://www.dropbox.com/oauth2/authorize',
|
|
48
|
+
tokenUrl: 'https://api.dropboxapi.com/oauth2/token',
|
|
49
|
+
authParams: {
|
|
50
|
+
token_access_type: 'offline' // Required for refresh token
|
|
51
|
+
},
|
|
52
|
+
tokenExchange: 'form-encoded',
|
|
53
|
+
scopes: ['files.content.write', 'files.content.read', 'files.metadata.write']
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get provider config by ID
|
|
59
|
+
* @throws Error if provider not found
|
|
60
|
+
*/
|
|
61
|
+
export function getProviderConfig(providerId: string): OAuthProviderConfig {
|
|
62
|
+
const config = OAUTH_PROVIDERS[providerId]
|
|
63
|
+
if (!config) {
|
|
64
|
+
throw new Error(`Unknown OAuth provider: ${providerId}`)
|
|
65
|
+
}
|
|
66
|
+
return config
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* List all available providers (for frontend)
|
|
71
|
+
*/
|
|
72
|
+
export function listProviders(): OAuthProviderConfig[] {
|
|
73
|
+
return Object.values(OAUTH_PROVIDERS)
|
|
74
|
+
}
|
|
@@ -1,39 +1,43 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OAuth Provider Credentials
|
|
3
|
-
* Maps provider IDs to environment variable names
|
|
4
|
-
* Single source of truth for credential lookup
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
const PROVIDER_ENV_VARS: Record<string, { clientIdEnv: string; clientSecretEnv: string }> = {
|
|
8
|
-
'google-sheets': {
|
|
9
|
-
clientIdEnv: 'GOOGLE_OAUTH_CLIENT_ID',
|
|
10
|
-
clientSecretEnv: 'GOOGLE_OAUTH_CLIENT_SECRET'
|
|
11
|
-
},
|
|
12
|
-
|
|
13
|
-
clientIdEnv: '
|
|
14
|
-
clientSecretEnv: '
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Provider Credentials
|
|
3
|
+
* Maps provider IDs to environment variable names
|
|
4
|
+
* Single source of truth for credential lookup
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const PROVIDER_ENV_VARS: Record<string, { clientIdEnv: string; clientSecretEnv: string }> = {
|
|
8
|
+
'google-sheets': {
|
|
9
|
+
clientIdEnv: 'GOOGLE_OAUTH_CLIENT_ID',
|
|
10
|
+
clientSecretEnv: 'GOOGLE_OAUTH_CLIENT_SECRET'
|
|
11
|
+
},
|
|
12
|
+
'google-calendar': {
|
|
13
|
+
clientIdEnv: 'GOOGLE_OAUTH_CLIENT_ID',
|
|
14
|
+
clientSecretEnv: 'GOOGLE_OAUTH_CLIENT_SECRET'
|
|
15
|
+
},
|
|
16
|
+
dropbox: {
|
|
17
|
+
clientIdEnv: 'DROPBOX_APP_KEY',
|
|
18
|
+
clientSecretEnv: 'DROPBOX_APP_SECRET'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get OAuth client credentials for a provider
|
|
24
|
+
* @throws Error if provider unknown or credentials missing
|
|
25
|
+
*/
|
|
26
|
+
export function getOAuthCredentials(providerId: string): { clientId: string; clientSecret: string } {
|
|
27
|
+
const config = PROVIDER_ENV_VARS[providerId]
|
|
28
|
+
if (!config) {
|
|
29
|
+
throw new Error(`Unknown OAuth provider: ${providerId}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const clientId = process.env[config.clientIdEnv]
|
|
33
|
+
const clientSecret = process.env[config.clientSecretEnv]
|
|
34
|
+
|
|
35
|
+
if (!clientId) {
|
|
36
|
+
throw new Error(`Missing environment variable: ${config.clientIdEnv}`)
|
|
37
|
+
}
|
|
38
|
+
if (!clientSecret) {
|
|
39
|
+
throw new Error(`Missing environment variable: ${config.clientSecretEnv}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { clientId, clientSecret }
|
|
43
|
+
}
|
|
@@ -384,6 +384,95 @@ describe('parsePath — invalid inputs', () => {
|
|
|
384
384
|
})
|
|
385
385
|
})
|
|
386
386
|
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// parsePath — edge cases
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
describe('parsePath edge cases', () => {
|
|
392
|
+
// Whitespace handling — the parser does NOT trim the incoming string.
|
|
393
|
+
// A path with leading whitespace does not start with '/' so it throws.
|
|
394
|
+
it('throws on path with leading whitespace', () => {
|
|
395
|
+
expect(() => parsePath(' /by-kind/playbook')).toThrow('parsePath: path must start with "/"')
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
// Trailing whitespace becomes part of the last segment (not stripped).
|
|
399
|
+
// The segment is non-empty so parsing proceeds and the whitespace is
|
|
400
|
+
// included verbatim in the returned arg.
|
|
401
|
+
it('preserves trailing whitespace inside the last segment', () => {
|
|
402
|
+
const result = parsePath('/by-kind/playbook ')
|
|
403
|
+
expect(result.mount).toBe('by-kind')
|
|
404
|
+
expect(result.args[0]).toBe('playbook ')
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
// Multiple trailing slashes are all stripped (regex /\/+$/).
|
|
408
|
+
it('strips multiple trailing slashes', () => {
|
|
409
|
+
expect(parsePath('/by-kind/playbook///')).toEqual({ mount: 'by-kind', args: ['playbook'] })
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// Lone-slash variants — a path of only slashes normalises to an empty
|
|
413
|
+
// segments list and must throw the root-with-no-mount error.
|
|
414
|
+
it('throws on path consisting only of slashes', () => {
|
|
415
|
+
expect(() => parsePath('///')).toThrow('parsePath: path resolves to root with no mount')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// Case sensitivity — parsePath does not normalise case.
|
|
419
|
+
// 'Playbook' (capitalised) is passed through as-is to the caller.
|
|
420
|
+
it('preserves node-id case (no case normalisation)', () => {
|
|
421
|
+
// /by-kind takes rest[0] verbatim
|
|
422
|
+
const result = parsePath('/by-kind/Playbook')
|
|
423
|
+
expect(result.args[0]).toBe('Playbook')
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
// Feature ID: dot notation passes through unchanged.
|
|
427
|
+
it('/by-feature/sales.crm preserves dot notation', () => {
|
|
428
|
+
expect(parsePath('/by-feature/sales.crm')).toEqual({ mount: 'by-feature', args: ['sales.crm'] })
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
// Feature ID: slash notation joins rest segments with '/'.
|
|
432
|
+
// The parser does NOT convert slashes to dots — the caller must normalise.
|
|
433
|
+
it('/by-feature/sales/crm joins segments with slash (no dot conversion)', () => {
|
|
434
|
+
const result = parsePath('/by-feature/sales/crm')
|
|
435
|
+
expect(result.mount).toBe('by-feature')
|
|
436
|
+
// args[0] is 'sales/crm', NOT 'sales.crm'
|
|
437
|
+
expect(result.args[0]).toBe('sales/crm')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
// /by-kind silently ignores extra path segments beyond the kind argument.
|
|
441
|
+
it('/by-kind/playbook/extra silently uses only first kind segment', () => {
|
|
442
|
+
// First segment after 'by-kind' is taken; extra is ignored.
|
|
443
|
+
const result = parsePath('/by-kind/playbook/extra')
|
|
444
|
+
expect(result.mount).toBe('by-kind')
|
|
445
|
+
expect(result.args[0]).toBe('playbook')
|
|
446
|
+
expect(result.args).toHaveLength(1)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// /by-owner joins rest with '/' (same behaviour as /by-feature).
|
|
450
|
+
it('/by-owner/role.ops-lead/sub joins segments with slash', () => {
|
|
451
|
+
const result = parsePath('/by-owner/role.ops-lead/sub')
|
|
452
|
+
expect(result.mount).toBe('by-owner')
|
|
453
|
+
expect(result.args[0]).toBe('role.ops-lead/sub')
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
// Graph node ID may contain a colon prefix (e.g. knowledge:knowledge.foo).
|
|
457
|
+
// The parser accepts it — the rest before the verb is joined with '/'.
|
|
458
|
+
it('/graph/<prefixed-nodeId>/governs round-trips the prefixed id', () => {
|
|
459
|
+
const result = parsePath('/graph/knowledge:knowledge.test-node/governs')
|
|
460
|
+
expect(result.mount).toBe('graph')
|
|
461
|
+
expect(result.args[0]).toBe('knowledge:knowledge.test-node')
|
|
462
|
+
expect(result.args[1]).toBe('governs')
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
// /graph with no arguments at all (just '/graph').
|
|
466
|
+
it('throws on /graph with no nodeId or verb', () => {
|
|
467
|
+
expect(() => parsePath('/graph')).toThrow('/graph requires <nodeId>/<verb>')
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
// /node mount — single-segment path works for any non-reserved first segment.
|
|
471
|
+
it('single-segment non-reserved path is treated as node mount', () => {
|
|
472
|
+
expect(parsePath('/knowledge.my-doc')).toEqual({ mount: 'node', args: ['knowledge.my-doc'] })
|
|
473
|
+
})
|
|
474
|
+
})
|
|
475
|
+
|
|
387
476
|
// ---------------------------------------------------------------------------
|
|
388
477
|
// formatText
|
|
389
478
|
// ---------------------------------------------------------------------------
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { buildOrganizationGraph } from '../graph/build'
|
|
3
|
+
import { CAPABILITY_REGISTRY } from '../domains/prospecting'
|
|
3
4
|
import { resolveOrganizationModel } from '../resolve'
|
|
4
5
|
|
|
5
6
|
describe('organization graph', () => {
|
|
@@ -11,7 +12,9 @@ describe('organization graph', () => {
|
|
|
11
12
|
sourceId: 'sales.crm',
|
|
12
13
|
label: 'CRM'
|
|
13
14
|
})
|
|
14
|
-
expect(
|
|
15
|
+
expect(
|
|
16
|
+
graph.edges.find((edge) => edge.sourceId === 'feature:sales' && edge.targetId === 'feature:sales.crm')
|
|
17
|
+
).toMatchObject({
|
|
15
18
|
kind: 'contains'
|
|
16
19
|
})
|
|
17
20
|
})
|
|
@@ -74,9 +77,112 @@ describe('organization graph', () => {
|
|
|
74
77
|
kind: 'resource',
|
|
75
78
|
label: 'missing-resource'
|
|
76
79
|
})
|
|
77
|
-
expect(graph.edges.find((edge) => edge.
|
|
80
|
+
expect(graph.edges.find((edge) => edge.kind === 'references' && edge.relationshipType === 'uses')).toMatchObject({
|
|
78
81
|
kind: 'references',
|
|
79
82
|
relationshipType: 'uses'
|
|
80
83
|
})
|
|
81
84
|
})
|
|
85
|
+
|
|
86
|
+
it('projects stage nodes from prospecting lifecycle stages', () => {
|
|
87
|
+
const graph = buildOrganizationGraph({
|
|
88
|
+
organizationModel: resolveOrganizationModel({
|
|
89
|
+
features: [{ id: 'operations', label: 'Operations', enabled: true, path: '/operations' }]
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const stageNodes = graph.nodes.filter((node) => node.kind === 'stage')
|
|
94
|
+
expect(stageNodes.length).toBeGreaterThan(0)
|
|
95
|
+
|
|
96
|
+
const populated = graph.nodes.find((node) => node.id === 'stage:populated')
|
|
97
|
+
expect(populated).toMatchObject({ kind: 'stage', sourceId: 'populated' })
|
|
98
|
+
expect(populated?.label).toBeTruthy()
|
|
99
|
+
|
|
100
|
+
expect(graph.edges.find((edge) => edge.kind === 'contains' && edge.targetId === 'stage:populated')).toMatchObject({
|
|
101
|
+
sourceId: 'organization-model'
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('projects capability nodes from CAPABILITY_REGISTRY with maps_to edges to resources', () => {
|
|
106
|
+
const graph = buildOrganizationGraph({
|
|
107
|
+
organizationModel: resolveOrganizationModel({
|
|
108
|
+
features: [{ id: 'operations', label: 'Operations', enabled: true, path: '/operations' }]
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const capabilityNodes = graph.nodes.filter((node) => node.kind === 'capability')
|
|
113
|
+
expect(capabilityNodes).toHaveLength(CAPABILITY_REGISTRY.length)
|
|
114
|
+
|
|
115
|
+
const sample = CAPABILITY_REGISTRY[0]
|
|
116
|
+
const node = graph.nodes.find((n) => n.id === `capability:${sample.id}`)
|
|
117
|
+
expect(node).toMatchObject({
|
|
118
|
+
kind: 'capability',
|
|
119
|
+
sourceId: sample.id,
|
|
120
|
+
label: sample.label,
|
|
121
|
+
description: sample.description
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
expect(
|
|
125
|
+
graph.edges.find(
|
|
126
|
+
(edge) =>
|
|
127
|
+
edge.kind === 'maps_to' &&
|
|
128
|
+
edge.sourceId === `capability:${sample.id}` &&
|
|
129
|
+
edge.targetId === `resource:${sample.resourceId}`
|
|
130
|
+
)
|
|
131
|
+
).toBeDefined()
|
|
132
|
+
|
|
133
|
+
expect(graph.nodes.find((n) => n.id === `resource:${sample.resourceId}`)).toBeDefined()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('emits uses edges from stages to capabilities for every prospecting build-template step', () => {
|
|
137
|
+
const model = resolveOrganizationModel({
|
|
138
|
+
features: [{ id: 'operations', label: 'Operations', enabled: true, path: '/operations' }]
|
|
139
|
+
})
|
|
140
|
+
const graph = buildOrganizationGraph({ organizationModel: model })
|
|
141
|
+
|
|
142
|
+
for (const template of model.prospecting.buildTemplates) {
|
|
143
|
+
for (const step of template.steps) {
|
|
144
|
+
const stageNodeId = `stage:${step.stageKey}`
|
|
145
|
+
const capNodeId = `capability:${step.capabilityKey}`
|
|
146
|
+
expect(
|
|
147
|
+
graph.edges.find(
|
|
148
|
+
(edge) =>
|
|
149
|
+
edge.kind === 'uses' &&
|
|
150
|
+
edge.sourceId === stageNodeId &&
|
|
151
|
+
edge.targetId === capNodeId &&
|
|
152
|
+
edge.id.includes(step.id)
|
|
153
|
+
),
|
|
154
|
+
`${template.id}.${step.id}: ${stageNodeId} uses ${capNodeId}`
|
|
155
|
+
).toBeDefined()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('emits references edges between stages for every step dependsOn link', () => {
|
|
161
|
+
const model = resolveOrganizationModel({
|
|
162
|
+
features: [{ id: 'operations', label: 'Operations', enabled: true, path: '/operations' }]
|
|
163
|
+
})
|
|
164
|
+
const graph = buildOrganizationGraph({ organizationModel: model })
|
|
165
|
+
|
|
166
|
+
for (const template of model.prospecting.buildTemplates) {
|
|
167
|
+
const stepById = new Map(template.steps.map((s) => [s.id, s]))
|
|
168
|
+
for (const step of template.steps) {
|
|
169
|
+
for (const depId of step.dependsOn ?? []) {
|
|
170
|
+
const depStep = stepById.get(depId)
|
|
171
|
+
if (!depStep) continue
|
|
172
|
+
const stageNodeId = `stage:${step.stageKey}`
|
|
173
|
+
const depStageNodeId = `stage:${depStep.stageKey}`
|
|
174
|
+
expect(
|
|
175
|
+
graph.edges.find(
|
|
176
|
+
(edge) =>
|
|
177
|
+
edge.kind === 'references' &&
|
|
178
|
+
edge.sourceId === stageNodeId &&
|
|
179
|
+
edge.targetId === depStageNodeId &&
|
|
180
|
+
edge.id.includes(step.id)
|
|
181
|
+
),
|
|
182
|
+
`${template.id}.${step.id} dependsOn ${depId}`
|
|
183
|
+
).toBeDefined()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
})
|
|
82
188
|
})
|