@nextsparkjs/theme-crm 0.1.0-beta.18 → 0.1.0-beta.20

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.
Files changed (70) hide show
  1. package/package.json +2 -2
  2. package/tests/cypress/e2e/api/activities/activities-crud.cy.ts +686 -0
  3. package/tests/cypress/e2e/api/campaigns/campaigns-crud.cy.ts +592 -0
  4. package/tests/cypress/e2e/api/companies/companies-crud.cy.ts +682 -0
  5. package/tests/cypress/e2e/api/contacts/contacts-crud.cy.ts +668 -0
  6. package/tests/cypress/e2e/api/leads/leads-crud.cy.ts +648 -0
  7. package/tests/cypress/e2e/api/notes/notes-crud.cy.ts +424 -0
  8. package/tests/cypress/e2e/api/opportunities/opportunities-crud.cy.ts +865 -0
  9. package/tests/cypress/e2e/api/pipelines/pipelines-crud.cy.ts +545 -0
  10. package/tests/cypress/e2e/api/products/products-crud.cy.ts +447 -0
  11. package/tests/cypress/e2e/ui/activities/activities-admin.cy.ts +268 -0
  12. package/tests/cypress/e2e/ui/activities/activities-member.cy.ts +257 -0
  13. package/tests/cypress/e2e/ui/activities/activities-owner.cy.ts +268 -0
  14. package/tests/cypress/e2e/ui/companies/companies-admin.cy.ts +188 -0
  15. package/tests/cypress/e2e/ui/companies/companies-member.cy.ts +166 -0
  16. package/tests/cypress/e2e/ui/companies/companies-owner.cy.ts +189 -0
  17. package/tests/cypress/e2e/ui/contacts/contacts-admin.cy.ts +252 -0
  18. package/tests/cypress/e2e/ui/contacts/contacts-member.cy.ts +224 -0
  19. package/tests/cypress/e2e/ui/contacts/contacts-owner.cy.ts +236 -0
  20. package/tests/cypress/e2e/ui/leads/leads-admin.cy.ts +286 -0
  21. package/tests/cypress/e2e/ui/leads/leads-member.cy.ts +193 -0
  22. package/tests/cypress/e2e/ui/leads/leads-owner.cy.ts +210 -0
  23. package/tests/cypress/e2e/ui/opportunities/opportunities-admin.cy.ts +197 -0
  24. package/tests/cypress/e2e/ui/opportunities/opportunities-member.cy.ts +229 -0
  25. package/tests/cypress/e2e/ui/opportunities/opportunities-owner.cy.ts +196 -0
  26. package/tests/cypress/e2e/ui/pipelines/pipelines-admin.cy.ts +320 -0
  27. package/tests/cypress/e2e/ui/pipelines/pipelines-member.cy.ts +262 -0
  28. package/tests/cypress/e2e/ui/pipelines/pipelines-owner.cy.ts +282 -0
  29. package/tests/cypress/fixtures/blocks.json +9 -0
  30. package/tests/cypress/fixtures/entities.json +240 -0
  31. package/tests/cypress/src/components/CRMDataTable.js +223 -0
  32. package/tests/cypress/src/components/CRMMobileNav.js +138 -0
  33. package/tests/cypress/src/components/CRMSidebar.js +145 -0
  34. package/tests/cypress/src/components/CRMTopBar.js +194 -0
  35. package/tests/cypress/src/components/DealCard.js +197 -0
  36. package/tests/cypress/src/components/EntityDetail.ts +290 -0
  37. package/tests/cypress/src/components/EntityForm.ts +357 -0
  38. package/tests/cypress/src/components/EntityList.ts +360 -0
  39. package/tests/cypress/src/components/PipelineKanban.js +204 -0
  40. package/tests/cypress/src/components/StageColumn.js +196 -0
  41. package/tests/cypress/src/components/index.js +13 -0
  42. package/tests/cypress/src/components/index.ts +22 -0
  43. package/tests/cypress/src/controllers/ActivityAPIController.ts +113 -0
  44. package/tests/cypress/src/controllers/BaseAPIController.ts +307 -0
  45. package/tests/cypress/src/controllers/CampaignAPIController.ts +114 -0
  46. package/tests/cypress/src/controllers/CompanyAPIController.ts +112 -0
  47. package/tests/cypress/src/controllers/ContactAPIController.ts +104 -0
  48. package/tests/cypress/src/controllers/LeadAPIController.ts +96 -0
  49. package/tests/cypress/src/controllers/NoteAPIController.ts +130 -0
  50. package/tests/cypress/src/controllers/OpportunityAPIController.ts +134 -0
  51. package/tests/cypress/src/controllers/PipelineAPIController.ts +116 -0
  52. package/tests/cypress/src/controllers/ProductAPIController.ts +113 -0
  53. package/tests/cypress/src/controllers/index.ts +35 -0
  54. package/tests/cypress/src/entities/ActivitiesPOM.ts +130 -0
  55. package/tests/cypress/src/entities/CompaniesPOM.ts +117 -0
  56. package/tests/cypress/src/entities/ContactsPOM.ts +117 -0
  57. package/tests/cypress/src/entities/LeadsPOM.ts +129 -0
  58. package/tests/cypress/src/entities/OpportunitiesPOM.ts +178 -0
  59. package/tests/cypress/src/entities/PipelinesPOM.ts +341 -0
  60. package/tests/cypress/src/entities/index.ts +31 -0
  61. package/tests/cypress/src/forms/OpportunityForm.js +316 -0
  62. package/tests/cypress/src/forms/PipelineForm.js +243 -0
  63. package/tests/cypress/src/forms/index.js +8 -0
  64. package/tests/cypress/src/index.js +22 -0
  65. package/tests/cypress/src/index.ts +68 -0
  66. package/tests/cypress/src/selectors.ts +50 -0
  67. package/tests/cypress/src/session-helpers.ts +94 -0
  68. package/tests/cypress/support/e2e.ts +89 -0
  69. package/tests/cypress.config.ts +165 -0
  70. package/tests/tsconfig.json +15 -0
@@ -0,0 +1,865 @@
1
+ /**
2
+ * Opportunities API - CRUD Tests
3
+ *
4
+ * Comprehensive test suite for Opportunity API endpoints.
5
+ * Tests GET, POST, PATCH, DELETE operations.
6
+ *
7
+ * Entity characteristics:
8
+ * - Required fields: name, companyId, pipelineId, stageId, amount, currency, closeDate
9
+ * - Optional fields: contactId, probability, type, source, competitor, status, lostReason, assignedTo
10
+ * - Access: shared within team (all team members see all opportunities)
11
+ * - Team context: required (x-team-id header)
12
+ * - Special: Stage moves, win/loss tracking, weighted value calculation
13
+ */
14
+
15
+ /// <reference types="cypress" />
16
+
17
+ import {
18
+ OpportunityAPIController,
19
+ CompanyAPIController,
20
+ PipelineAPIController
21
+ } from '../../../src/controllers'
22
+
23
+ describe('Opportunities API - CRUD Operations', () => {
24
+ // Test constants
25
+ const SUPERADMIN_API_KEY = 'test_api_key_for_testing_purposes_only_not_a_real_secret_key_abc123'
26
+ const TEAM_ID = 'team-tmt-001'
27
+ const BASE_URL = Cypress.config('baseUrl') || 'http://localhost:5173'
28
+
29
+ // Controller instances
30
+ let opportunityAPI: InstanceType<typeof OpportunityAPIController>
31
+ let companyAPI: InstanceType<typeof CompanyAPIController>
32
+ let pipelineAPI: InstanceType<typeof PipelineAPIController>
33
+
34
+ // Track created resources for cleanup
35
+ let createdOpportunities: any[] = []
36
+ let createdCompanies: any[] = []
37
+ let createdPipelines: any[] = []
38
+
39
+ // Test fixtures (created once in before hook)
40
+ let testCompany: any
41
+ let testPipeline: any
42
+ let testStageId: string
43
+
44
+ before(() => {
45
+ // Initialize controllers with superadmin credentials
46
+ opportunityAPI = new OpportunityAPIController(BASE_URL, SUPERADMIN_API_KEY, TEAM_ID)
47
+ companyAPI = new CompanyAPIController(BASE_URL, SUPERADMIN_API_KEY, TEAM_ID)
48
+ pipelineAPI = new PipelineAPIController(BASE_URL, SUPERADMIN_API_KEY, TEAM_ID)
49
+
50
+ // Create test company (shared across tests) - with retry for 500 errors
51
+ cy.wrap(null).then(() => {
52
+ return companyAPI.createTestRecord({
53
+ name: `Test Company for Opportunities ${Date.now()}`,
54
+ type: 'prospect',
55
+ size: '11-50'
56
+ }, { withRetry: true }).then((company: any) => {
57
+ testCompany = company
58
+ createdCompanies.push(testCompany)
59
+ cy.log(`Created test company: ${testCompany.id}`)
60
+ })
61
+ })
62
+
63
+ // Create test pipeline with stages (shared across tests)
64
+ cy.wrap(null).then(() => {
65
+ return pipelineAPI.createTestRecord({
66
+ name: 'Test Pipeline for Opportunities',
67
+ stages: [
68
+ { name: 'Qualification', probability: 10, order: 1 },
69
+ { name: 'Proposal', probability: 50, order: 2 },
70
+ { name: 'Negotiation', probability: 75, order: 3 },
71
+ { name: 'Closed Won', probability: 100, order: 4 }
72
+ ]
73
+ }, { withRetry: true }).then((pipeline: any) => {
74
+ testPipeline = pipeline
75
+ createdPipelines.push(pipeline)
76
+ // Get first stage ID for testing
77
+ if (pipeline.stages && pipeline.stages.length > 0) {
78
+ testStageId = pipeline.stages[0].id || `stage-${Date.now()}`
79
+ }
80
+ cy.log(`Created test pipeline: ${testPipeline.id} with ${pipeline.stages?.length || 0} stages`)
81
+ })
82
+ })
83
+ })
84
+
85
+ afterEach(() => {
86
+ // Cleanup created opportunities after each test
87
+ createdOpportunities.forEach((opportunity) => {
88
+ if (opportunity?.id) {
89
+ opportunityAPI.delete(opportunity.id)
90
+ }
91
+ })
92
+ createdOpportunities = []
93
+ // Small delay to allow database connections to be released
94
+ cy.wait(200)
95
+ })
96
+
97
+ after(() => {
98
+ // Cleanup shared test fixtures
99
+ if (testCompany?.id) {
100
+ companyAPI.delete(testCompany.id)
101
+ }
102
+ if (testPipeline?.id) {
103
+ pipelineAPI.delete(testPipeline.id)
104
+ }
105
+ })
106
+
107
+ // ============================================
108
+ // GET /api/v1/opportunities - List Opportunities
109
+ // ============================================
110
+ describe('GET /api/v1/opportunities - List Opportunities', () => {
111
+ it('OPPO_API_001: Should list opportunities with valid API key', () => {
112
+ opportunityAPI.getAll().then((response: any) => {
113
+ opportunityAPI.validateSuccessResponse(response, 200)
114
+ expect(response.body.data).to.be.an('array')
115
+ expect(response.body.info).to.have.property('page')
116
+ expect(response.body.info).to.have.property('limit')
117
+ expect(response.body.info).to.have.property('total')
118
+ expect(response.body.info).to.have.property('totalPages')
119
+
120
+ cy.log(`Found ${response.body.data.length} opportunities`)
121
+ })
122
+ })
123
+
124
+ it('OPPO_API_002: Should list opportunities with pagination', () => {
125
+ opportunityAPI.getAll({ page: 1, limit: 5 }).then((response: any) => {
126
+ opportunityAPI.validateSuccessResponse(response, 200)
127
+ expect(response.body.info.page).to.eq(1)
128
+ expect(response.body.info.limit).to.eq(5)
129
+ expect(response.body.data.length).to.be.at.most(5)
130
+
131
+ cy.log(`Page 1 with limit 5: ${response.body.data.length} opportunities`)
132
+ })
133
+ })
134
+
135
+ it('OPPO_API_003: Should filter opportunities by pipelineId', () => {
136
+ // First create an opportunity with the test pipeline
137
+ const opportunityData = opportunityAPI.generateRandomData({
138
+ companyId: testCompany.id,
139
+ pipelineId: testPipeline.id,
140
+ stageId: testStageId
141
+ })
142
+
143
+ opportunityAPI.create(opportunityData).then((createResponse: any) => {
144
+ expect(createResponse.status).to.eq(201)
145
+ createdOpportunities.push(createResponse.body.data)
146
+
147
+ // Now filter by that pipeline
148
+ opportunityAPI.getAll({ pipelineId: testPipeline.id }).then((response: any) => {
149
+ opportunityAPI.validateSuccessResponse(response, 200)
150
+ expect(response.body.data).to.be.an('array')
151
+
152
+ // All returned opportunities should have the specified pipelineId
153
+ response.body.data.forEach((opportunity: any) => {
154
+ expect(opportunity.pipelineId).to.eq(testPipeline.id)
155
+ })
156
+
157
+ cy.log(`Found ${response.body.data.length} opportunities for pipeline '${testPipeline.id}'`)
158
+ })
159
+ })
160
+ })
161
+
162
+ it('OPPO_API_004: Should filter opportunities by stageId', () => {
163
+ // Create an opportunity with specific stage
164
+ const opportunityData = opportunityAPI.generateRandomData({
165
+ companyId: testCompany.id,
166
+ pipelineId: testPipeline.id,
167
+ stageId: testStageId
168
+ })
169
+
170
+ opportunityAPI.create(opportunityData).then((createResponse: any) => {
171
+ expect(createResponse.status).to.eq(201)
172
+ createdOpportunities.push(createResponse.body.data)
173
+
174
+ // Filter by that stage
175
+ opportunityAPI.getAll({ stageId: testStageId }).then((response: any) => {
176
+ opportunityAPI.validateSuccessResponse(response, 200)
177
+ expect(response.body.data).to.be.an('array')
178
+
179
+ // All returned opportunities should have the specified stageId
180
+ response.body.data.forEach((opportunity: any) => {
181
+ expect(opportunity.stageId).to.eq(testStageId)
182
+ })
183
+
184
+ cy.log(`Found ${response.body.data.length} opportunities in stage '${testStageId}'`)
185
+ })
186
+ })
187
+ })
188
+
189
+ it('OPPO_API_005: Should filter opportunities by status', () => {
190
+ // Create an opportunity with specific status
191
+ const opportunityData = opportunityAPI.generateRandomData({
192
+ companyId: testCompany.id,
193
+ pipelineId: testPipeline.id,
194
+ stageId: testStageId,
195
+ status: 'open'
196
+ })
197
+
198
+ opportunityAPI.create(opportunityData).then((createResponse: any) => {
199
+ expect(createResponse.status).to.eq(201)
200
+ createdOpportunities.push(createResponse.body.data)
201
+
202
+ // Filter by that status
203
+ opportunityAPI.getAll({ status: 'open' }).then((response: any) => {
204
+ opportunityAPI.validateSuccessResponse(response, 200)
205
+ expect(response.body.data).to.be.an('array')
206
+
207
+ // All returned opportunities should have the specified status
208
+ response.body.data.forEach((opportunity: any) => {
209
+ expect(opportunity.status).to.eq('open')
210
+ })
211
+
212
+ cy.log(`Found ${response.body.data.length} opportunities with status 'open'`)
213
+ })
214
+ })
215
+ })
216
+
217
+ it('OPPO_API_006: Should search opportunities by name', () => {
218
+ // Create an opportunity with unique searchable term
219
+ const uniqueTerm = `SearchOppo${Date.now()}`
220
+ const opportunityData = opportunityAPI.generateRandomData({
221
+ name: `${uniqueTerm} Deal`,
222
+ companyId: testCompany.id,
223
+ pipelineId: testPipeline.id,
224
+ stageId: testStageId
225
+ })
226
+
227
+ opportunityAPI.create(opportunityData).then((createResponse: any) => {
228
+ expect(createResponse.status).to.eq(201)
229
+ createdOpportunities.push(createResponse.body.data)
230
+
231
+ // Search for the unique term
232
+ opportunityAPI.getAll({ search: uniqueTerm }).then((response: any) => {
233
+ opportunityAPI.validateSuccessResponse(response, 200)
234
+ expect(response.body.data).to.be.an('array')
235
+ expect(response.body.data.length).to.be.greaterThan(0)
236
+
237
+ // Verify the found opportunity contains our search term
238
+ const foundOpportunity = response.body.data.find(
239
+ (o: any) => o.id === createResponse.body.data.id
240
+ )
241
+ expect(foundOpportunity).to.exist
242
+ expect(foundOpportunity.name).to.include(uniqueTerm)
243
+
244
+ cy.log(`Search found ${response.body.data.length} opportunities matching '${uniqueTerm}'`)
245
+ })
246
+ })
247
+ })
248
+
249
+ it('OPPO_API_007: Should reject request without API key', () => {
250
+ const noAuthAPI = new OpportunityAPIController(BASE_URL, null, TEAM_ID)
251
+
252
+ noAuthAPI.getAll().then((response: any) => {
253
+ expect(response.status).to.eq(401)
254
+ expect(response.body).to.have.property('success', false)
255
+
256
+ cy.log('Request without API key rejected with 401')
257
+ })
258
+ })
259
+
260
+ it('OPPO_API_008: Should reject request without x-team-id', () => {
261
+ const noTeamAPI = new OpportunityAPIController(BASE_URL, SUPERADMIN_API_KEY, null)
262
+
263
+ noTeamAPI.getAll().then((response: any) => {
264
+ expect(response.status).to.eq(400)
265
+ expect(response.body).to.have.property('success', false)
266
+ expect(response.body).to.have.property('code', 'TEAM_CONTEXT_REQUIRED')
267
+
268
+ cy.log('Request without x-team-id rejected with TEAM_CONTEXT_REQUIRED')
269
+ })
270
+ })
271
+ })
272
+
273
+ // ============================================
274
+ // POST /api/v1/opportunities - Create Opportunity
275
+ // ============================================
276
+ describe('POST /api/v1/opportunities - Create Opportunity', () => {
277
+ it('OPPO_API_010: Should create opportunity with valid data', () => {
278
+ const opportunityData = opportunityAPI.generateRandomData({
279
+ name: 'Enterprise Deal Q4',
280
+ companyId: testCompany.id,
281
+ pipelineId: testPipeline.id,
282
+ stageId: testStageId,
283
+ amount: 150000,
284
+ currency: 'USD',
285
+ closeDate: '2025-12-31',
286
+ probability: 50,
287
+ type: 'new_business',
288
+ source: 'web',
289
+ status: 'open'
290
+ })
291
+
292
+ opportunityAPI.create(opportunityData).then((response: any) => {
293
+ opportunityAPI.validateSuccessResponse(response, 201)
294
+ createdOpportunities.push(response.body.data)
295
+
296
+ const opportunity = response.body.data
297
+ opportunityAPI.validateObject(opportunity)
298
+
299
+ // Verify provided data
300
+ expect(opportunity.name).to.eq(opportunityData.name)
301
+ expect(opportunity.companyId).to.eq(opportunityData.companyId)
302
+ expect(opportunity.pipelineId).to.eq(opportunityData.pipelineId)
303
+ expect(opportunity.stageId).to.eq(opportunityData.stageId)
304
+ expect(Number(opportunity.amount)).to.eq(opportunityData.amount)
305
+ expect(opportunity.currency).to.eq(opportunityData.currency)
306
+ expect(opportunity.closeDate).to.include(opportunityData.closeDate.split('T')[0])
307
+ expect(Number(opportunity.probability)).to.eq(opportunityData.probability)
308
+ expect(opportunity.type).to.eq(opportunityData.type)
309
+ expect(opportunity.source).to.eq(opportunityData.source)
310
+ expect(opportunity.status).to.eq(opportunityData.status)
311
+
312
+ cy.log(`Created opportunity: ${opportunity.name} (ID: ${opportunity.id})`)
313
+ })
314
+ })
315
+
316
+ it('OPPO_API_011: Should create opportunity with minimal required fields', () => {
317
+ const minimalData = {
318
+ name: `Minimal Opportunity ${Date.now()}`,
319
+ companyId: testCompany.id,
320
+ pipelineId: testPipeline.id,
321
+ stageId: testStageId,
322
+ amount: 50000,
323
+ currency: 'USD',
324
+ closeDate: '2025-12-15'
325
+ }
326
+
327
+ opportunityAPI.create(minimalData).then((response: any) => {
328
+ opportunityAPI.validateSuccessResponse(response, 201)
329
+ createdOpportunities.push(response.body.data)
330
+
331
+ const opportunity = response.body.data
332
+ opportunityAPI.validateObject(opportunity)
333
+
334
+ // Verify required fields
335
+ expect(opportunity.name).to.eq(minimalData.name)
336
+ expect(opportunity.companyId).to.eq(minimalData.companyId)
337
+ expect(opportunity.pipelineId).to.eq(minimalData.pipelineId)
338
+ expect(opportunity.stageId).to.eq(minimalData.stageId)
339
+ expect(Number(opportunity.amount)).to.eq(minimalData.amount)
340
+ expect(opportunity.currency).to.eq(minimalData.currency)
341
+ expect(opportunity.closeDate).to.include(minimalData.closeDate.split('T')[0])
342
+
343
+ cy.log(`Created opportunity with minimal data: ${opportunity.id}`)
344
+ })
345
+ })
346
+
347
+ it('OPPO_API_012: Should create opportunity with all optional fields', () => {
348
+ const opportunityData = opportunityAPI.generateRandomData({
349
+ name: `Complete Opportunity ${Date.now()}`,
350
+ companyId: testCompany.id,
351
+ pipelineId: testPipeline.id,
352
+ stageId: testStageId,
353
+ amount: 200000,
354
+ currency: 'EUR',
355
+ closeDate: '2025-10-30',
356
+ probability: 75,
357
+ type: 'upgrade',
358
+ source: 'referral',
359
+ competitor: 'Competitor XYZ',
360
+ status: 'open'
361
+ })
362
+
363
+ opportunityAPI.create(opportunityData).then((response: any) => {
364
+ opportunityAPI.validateSuccessResponse(response, 201)
365
+ createdOpportunities.push(response.body.data)
366
+
367
+ const opportunity = response.body.data
368
+
369
+ // Verify all fields
370
+ expect(opportunity.name).to.eq(opportunityData.name)
371
+ expect(opportunity.companyId).to.eq(opportunityData.companyId)
372
+ expect(opportunity.pipelineId).to.eq(opportunityData.pipelineId)
373
+ expect(opportunity.stageId).to.eq(opportunityData.stageId)
374
+ expect(Number(opportunity.amount)).to.eq(opportunityData.amount)
375
+ expect(opportunity.currency).to.eq(opportunityData.currency)
376
+ expect(opportunity.closeDate).to.include(opportunityData.closeDate.split('T')[0])
377
+ expect(Number(opportunity.probability)).to.eq(opportunityData.probability)
378
+ expect(opportunity.type).to.eq(opportunityData.type)
379
+ expect(opportunity.source).to.eq(opportunityData.source)
380
+ expect(opportunity.competitor).to.eq(opportunityData.competitor)
381
+ expect(opportunity.status).to.eq(opportunityData.status)
382
+
383
+ cy.log(`Created opportunity with all fields: ${opportunity.id}`)
384
+ })
385
+ })
386
+
387
+ it('OPPO_API_013: Should reject creation without name', () => {
388
+ const invalidData = {
389
+ companyId: testCompany.id,
390
+ pipelineId: testPipeline.id,
391
+ stageId: testStageId,
392
+ amount: 50000,
393
+ currency: 'USD',
394
+ closeDate: '2025-12-31'
395
+ // Missing: name
396
+ }
397
+
398
+ opportunityAPI.create(invalidData).then((response: any) => {
399
+ opportunityAPI.validateErrorResponse(response, 400, 'VALIDATION_ERROR')
400
+
401
+ cy.log('Creation without name rejected with VALIDATION_ERROR')
402
+ })
403
+ })
404
+
405
+ it('OPPO_API_014: Should allow creation without companyId (nullable relation)', () => {
406
+ // Note: companyId is nullable in the database even though entity marks it required
407
+ const minimalData = {
408
+ name: 'Test Opportunity',
409
+ pipelineId: testPipeline.id,
410
+ stageId: testStageId,
411
+ amount: 50000,
412
+ currency: 'USD',
413
+ closeDate: '2025-12-31'
414
+ // Missing: companyId - API may allow null
415
+ }
416
+
417
+ opportunityAPI.create(minimalData).then((response: any) => {
418
+ if (response.status === 201) {
419
+ createdOpportunities.push(response.body.data)
420
+ cy.log('Creation without companyId allowed - nullable relation')
421
+ } else {
422
+ expect(response.status).to.eq(400)
423
+ cy.log('Creation without companyId rejected')
424
+ }
425
+ })
426
+ })
427
+
428
+ it('OPPO_API_015: Should allow creation without pipelineId (nullable relation)', () => {
429
+ // Note: pipelineId is nullable in the database even though entity marks it required
430
+ const minimalData = {
431
+ name: 'Test Opportunity',
432
+ companyId: testCompany.id,
433
+ stageId: testStageId,
434
+ amount: 50000,
435
+ currency: 'USD',
436
+ closeDate: '2025-12-31'
437
+ // Missing: pipelineId - API may allow null
438
+ }
439
+
440
+ opportunityAPI.create(minimalData).then((response: any) => {
441
+ if (response.status === 201) {
442
+ createdOpportunities.push(response.body.data)
443
+ cy.log('Creation without pipelineId allowed - nullable relation')
444
+ } else {
445
+ expect(response.status).to.eq(400)
446
+ cy.log('Creation without pipelineId rejected')
447
+ }
448
+ })
449
+ })
450
+
451
+ it('OPPO_API_016: Should reject creation without stageId', () => {
452
+ const invalidData = {
453
+ name: 'Test Opportunity',
454
+ companyId: testCompany.id,
455
+ pipelineId: testPipeline.id,
456
+ amount: 50000,
457
+ currency: 'USD',
458
+ closeDate: '2025-12-31'
459
+ // Missing: stageId
460
+ }
461
+
462
+ opportunityAPI.create(invalidData).then((response: any) => {
463
+ opportunityAPI.validateErrorResponse(response, 400, 'VALIDATION_ERROR')
464
+
465
+ cy.log('Creation without stageId rejected with VALIDATION_ERROR')
466
+ })
467
+ })
468
+
469
+ it('OPPO_API_017: Should reject creation without x-team-id', () => {
470
+ const noTeamAPI = new OpportunityAPIController(BASE_URL, SUPERADMIN_API_KEY, null)
471
+ const opportunityData = opportunityAPI.generateRandomData({
472
+ companyId: testCompany.id,
473
+ pipelineId: testPipeline.id,
474
+ stageId: testStageId
475
+ })
476
+
477
+ noTeamAPI.create(opportunityData).then((response: any) => {
478
+ expect(response.status).to.eq(400)
479
+ expect(response.body).to.have.property('success', false)
480
+ expect(response.body).to.have.property('code', 'TEAM_CONTEXT_REQUIRED')
481
+
482
+ cy.log('Creation without x-team-id rejected with TEAM_CONTEXT_REQUIRED')
483
+ })
484
+ })
485
+ })
486
+
487
+ // ============================================
488
+ // GET /api/v1/opportunities/{id} - Get Opportunity by ID
489
+ // ============================================
490
+ describe('GET /api/v1/opportunities/{id} - Get Opportunity by ID', () => {
491
+ it('OPPO_API_020: Should get opportunity by valid ID', () => {
492
+ // First create an opportunity
493
+ const opportunityData = opportunityAPI.generateRandomData({
494
+ companyId: testCompany.id,
495
+ pipelineId: testPipeline.id,
496
+ stageId: testStageId
497
+ })
498
+
499
+ opportunityAPI.create(opportunityData).then((createResponse: any) => {
500
+ expect(createResponse.status).to.eq(201)
501
+ createdOpportunities.push(createResponse.body.data)
502
+
503
+ const opportunityId = createResponse.body.data.id
504
+
505
+ // Get the opportunity by ID
506
+ opportunityAPI.getById(opportunityId).then((response: any) => {
507
+ opportunityAPI.validateSuccessResponse(response, 200)
508
+
509
+ const opportunity = response.body.data
510
+ opportunityAPI.validateObject(opportunity)
511
+ expect(opportunity.id).to.eq(opportunityId)
512
+ expect(opportunity.name).to.eq(opportunityData.name)
513
+
514
+ cy.log(`Retrieved opportunity: ${opportunity.name}`)
515
+ })
516
+ })
517
+ })
518
+
519
+ it('OPPO_API_021: Should return 404 for non-existent opportunity', () => {
520
+ const fakeId = 'non-existent-opportunity-id-12345'
521
+
522
+ opportunityAPI.getById(fakeId).then((response: any) => {
523
+ expect(response.status).to.eq(404)
524
+ expect(response.body).to.have.property('success', false)
525
+
526
+ cy.log('Non-existent opportunity returns 404')
527
+ })
528
+ })
529
+ })
530
+
531
+ // ============================================
532
+ // PATCH /api/v1/opportunities/{id} - Update Opportunity
533
+ // ============================================
534
+ describe('PATCH /api/v1/opportunities/{id} - Update Opportunity', () => {
535
+ it('OPPO_API_030: Should update opportunity with multiple fields', () => {
536
+ // First create an opportunity
537
+ opportunityAPI.createTestRecord({
538
+ companyId: testCompany.id,
539
+ pipelineId: testPipeline.id,
540
+ stageId: testStageId
541
+ }, { withRetry: true }).then((testOpportunity: any) => {
542
+ createdOpportunities.push(testOpportunity)
543
+
544
+ const updateData = {
545
+ name: 'Updated Opportunity Name',
546
+ amount: 250000,
547
+ probability: 80,
548
+ type: 'existing_business',
549
+ source: 'social_media'
550
+ }
551
+
552
+ opportunityAPI.update(testOpportunity.id, updateData).then((response: any) => {
553
+ opportunityAPI.validateSuccessResponse(response, 200)
554
+
555
+ const opportunity = response.body.data
556
+ expect(opportunity.name).to.eq(updateData.name)
557
+ expect(Number(opportunity.amount)).to.eq(updateData.amount)
558
+ expect(Number(opportunity.probability)).to.eq(updateData.probability)
559
+ expect(opportunity.type).to.eq(updateData.type)
560
+ expect(opportunity.source).to.eq(updateData.source)
561
+ // Original values should be preserved
562
+ expect(opportunity.companyId).to.eq(testOpportunity.companyId)
563
+ expect(opportunity.pipelineId).to.eq(testOpportunity.pipelineId)
564
+
565
+ cy.log(`Updated opportunity: ${opportunity.name}`)
566
+ })
567
+ })
568
+ })
569
+
570
+ it('OPPO_API_031: Should update stageId (move stage)', () => {
571
+ // Create opportunity in first stage
572
+ opportunityAPI.createTestRecord({
573
+ companyId: testCompany.id,
574
+ pipelineId: testPipeline.id,
575
+ stageId: testStageId
576
+ }, { withRetry: true }).then((testOpportunity: any) => {
577
+ createdOpportunities.push(testOpportunity)
578
+
579
+ // Get second stage ID
580
+ const secondStageId = testPipeline.stages[1]?.id || testStageId
581
+
582
+ // Move to second stage
583
+ opportunityAPI.update(testOpportunity.id, { stageId: secondStageId }).then((response: any) => {
584
+ opportunityAPI.validateSuccessResponse(response, 200)
585
+ expect(response.body.data.stageId).to.eq(secondStageId)
586
+
587
+ cy.log(`Moved opportunity to stage: ${secondStageId}`)
588
+ })
589
+ })
590
+ })
591
+
592
+ it('OPPO_API_032: Should update amount and probability', () => {
593
+ opportunityAPI.createTestRecord({
594
+ companyId: testCompany.id,
595
+ pipelineId: testPipeline.id,
596
+ stageId: testStageId
597
+ }, { withRetry: true }).then((testOpportunity: any) => {
598
+ createdOpportunities.push(testOpportunity)
599
+
600
+ const updateData = {
601
+ amount: 300000,
602
+ probability: 90
603
+ }
604
+
605
+ opportunityAPI.update(testOpportunity.id, updateData).then((response: any) => {
606
+ opportunityAPI.validateSuccessResponse(response, 200)
607
+ expect(Number(response.body.data.amount)).to.eq(updateData.amount)
608
+ expect(Number(response.body.data.probability)).to.eq(updateData.probability)
609
+
610
+ // Weighted value = amount * (probability / 100)
611
+ const expectedWeightedValue = updateData.amount * (updateData.probability / 100)
612
+ cy.log(`Updated amount: ${updateData.amount}, probability: ${updateData.probability}%`)
613
+ cy.log(`Expected weighted value: ${expectedWeightedValue}`)
614
+ })
615
+ })
616
+ })
617
+
618
+ it('OPPO_API_033: Should update status to won', () => {
619
+ opportunityAPI.createTestRecord({
620
+ companyId: testCompany.id,
621
+ pipelineId: testPipeline.id,
622
+ stageId: testStageId,
623
+ status: 'open'
624
+ }, { withRetry: true }).then((testOpportunity: any) => {
625
+ createdOpportunities.push(testOpportunity)
626
+
627
+ const updateData = {
628
+ status: 'won',
629
+ probability: 100
630
+ }
631
+
632
+ opportunityAPI.update(testOpportunity.id, updateData).then((response: any) => {
633
+ opportunityAPI.validateSuccessResponse(response, 200)
634
+ expect(response.body.data.status).to.eq('won')
635
+ expect(Number(response.body.data.probability)).to.eq(100)
636
+
637
+ cy.log(`Opportunity marked as WON`)
638
+ })
639
+ })
640
+ })
641
+
642
+ it('OPPO_API_034: Should update status to lost with lostReason', () => {
643
+ opportunityAPI.createTestRecord({
644
+ companyId: testCompany.id,
645
+ pipelineId: testPipeline.id,
646
+ stageId: testStageId,
647
+ status: 'open'
648
+ }, { withRetry: true }).then((testOpportunity: any) => {
649
+ createdOpportunities.push(testOpportunity)
650
+
651
+ const updateData = {
652
+ status: 'lost',
653
+ probability: 0,
654
+ lostReason: 'Price too high'
655
+ }
656
+
657
+ opportunityAPI.update(testOpportunity.id, updateData).then((response: any) => {
658
+ opportunityAPI.validateSuccessResponse(response, 200)
659
+ expect(response.body.data.status).to.eq('lost')
660
+ expect(Number(response.body.data.probability)).to.eq(0)
661
+ expect(response.body.data.lostReason).to.eq(updateData.lostReason)
662
+
663
+ cy.log(`Opportunity marked as LOST: ${updateData.lostReason}`)
664
+ })
665
+ })
666
+ })
667
+
668
+ it('OPPO_API_035: Should return 404 for non-existent opportunity', () => {
669
+ const fakeId = 'non-existent-opportunity-id-12345'
670
+
671
+ opportunityAPI.update(fakeId, { name: 'New Name' }).then((response: any) => {
672
+ expect(response.status).to.eq(404)
673
+ expect(response.body).to.have.property('success', false)
674
+
675
+ cy.log('Update non-existent opportunity returns 404')
676
+ })
677
+ })
678
+
679
+ it('OPPO_API_036: Should reject empty update body', () => {
680
+ opportunityAPI.createTestRecord({
681
+ companyId: testCompany.id,
682
+ pipelineId: testPipeline.id,
683
+ stageId: testStageId
684
+ }, { withRetry: true }).then((testOpportunity: any) => {
685
+ createdOpportunities.push(testOpportunity)
686
+
687
+ opportunityAPI.update(testOpportunity.id, {}).then((response: any) => {
688
+ expect(response.status).to.eq(400)
689
+ expect(response.body).to.have.property('success', false)
690
+ // Error code can be NO_FIELDS or HTTP_400 depending on validation layer
691
+ expect(response.body.code).to.be.oneOf(['NO_FIELDS', 'HTTP_400'])
692
+
693
+ cy.log('Empty update body rejected')
694
+ })
695
+ })
696
+ })
697
+ })
698
+
699
+ // ============================================
700
+ // DELETE /api/v1/opportunities/{id} - Delete Opportunity
701
+ // ============================================
702
+ describe('DELETE /api/v1/opportunities/{id} - Delete Opportunity', () => {
703
+ it('OPPO_API_040: Should delete opportunity by valid ID', () => {
704
+ // Create an opportunity to delete
705
+ const opportunityData = opportunityAPI.generateRandomData({
706
+ companyId: testCompany.id,
707
+ pipelineId: testPipeline.id,
708
+ stageId: testStageId
709
+ })
710
+
711
+ opportunityAPI.create(opportunityData).then((createResponse: any) => {
712
+ expect(createResponse.status).to.eq(201)
713
+ const opportunityId = createResponse.body.data.id
714
+
715
+ // Delete the opportunity
716
+ opportunityAPI.delete(opportunityId).then((response: any) => {
717
+ opportunityAPI.validateSuccessResponse(response, 200)
718
+ expect(response.body.data).to.have.property('success', true)
719
+ expect(response.body.data).to.have.property('id', opportunityId)
720
+
721
+ cy.log(`Deleted opportunity: ${opportunityId}`)
722
+ })
723
+ })
724
+ })
725
+
726
+ it('OPPO_API_041: Should return 404 for non-existent opportunity', () => {
727
+ const fakeId = 'non-existent-opportunity-id-12345'
728
+
729
+ opportunityAPI.delete(fakeId).then((response: any) => {
730
+ expect(response.status).to.eq(404)
731
+ expect(response.body).to.have.property('success', false)
732
+
733
+ cy.log('Delete non-existent opportunity returns 404')
734
+ })
735
+ })
736
+
737
+ it('OPPO_API_042: Should verify deletion persists', () => {
738
+ // Create an opportunity
739
+ const opportunityData = opportunityAPI.generateRandomData({
740
+ companyId: testCompany.id,
741
+ pipelineId: testPipeline.id,
742
+ stageId: testStageId
743
+ })
744
+
745
+ opportunityAPI.create(opportunityData).then((createResponse: any) => {
746
+ expect(createResponse.status).to.eq(201)
747
+ const opportunityId = createResponse.body.data.id
748
+
749
+ // Delete it
750
+ opportunityAPI.delete(opportunityId).then((deleteResponse: any) => {
751
+ expect(deleteResponse.status).to.eq(200)
752
+
753
+ // Verify it's gone
754
+ opportunityAPI.getById(opportunityId).then((getResponse: any) => {
755
+ expect(getResponse.status).to.eq(404)
756
+ expect(getResponse.body).to.have.property('success', false)
757
+
758
+ cy.log('Deletion verified - opportunity no longer exists')
759
+ })
760
+ })
761
+ })
762
+ })
763
+ })
764
+
765
+ // ============================================
766
+ // Integration - Complete CRUD Lifecycle
767
+ // ============================================
768
+ describe('Integration - Complete CRUD Lifecycle', () => {
769
+ it('OPPO_API_100: Should complete full lifecycle: Create -> Read -> Update -> Stage Move -> Win/Loss -> Delete', () => {
770
+ // 1. CREATE
771
+ const opportunityData = opportunityAPI.generateRandomData({
772
+ name: 'Lifecycle Test Opportunity',
773
+ companyId: testCompany.id,
774
+ pipelineId: testPipeline.id,
775
+ stageId: testStageId,
776
+ amount: 100000,
777
+ currency: 'USD',
778
+ closeDate: '2025-12-31',
779
+ probability: 25,
780
+ type: 'new_business',
781
+ source: 'web',
782
+ status: 'open'
783
+ })
784
+
785
+ opportunityAPI.create(opportunityData).then((createResponse: any) => {
786
+ opportunityAPI.validateSuccessResponse(createResponse, 201)
787
+ const opportunityId = createResponse.body.data.id
788
+
789
+ cy.log(`1. Created opportunity: ${opportunityId}`)
790
+
791
+ // 2. READ
792
+ opportunityAPI.getById(opportunityId).then((readResponse: any) => {
793
+ opportunityAPI.validateSuccessResponse(readResponse, 200)
794
+ expect(readResponse.body.data.name).to.eq(opportunityData.name)
795
+ expect(Number(readResponse.body.data.amount)).to.eq(opportunityData.amount)
796
+
797
+ cy.log(`2. Read opportunity: ${readResponse.body.data.name}`)
798
+
799
+ // 3. UPDATE BASIC FIELDS
800
+ const updateData = {
801
+ name: 'Updated Lifecycle Opportunity',
802
+ amount: 150000,
803
+ probability: 50,
804
+ type: 'upgrade'
805
+ }
806
+
807
+ opportunityAPI.update(opportunityId, updateData).then((updateResponse: any) => {
808
+ opportunityAPI.validateSuccessResponse(updateResponse, 200)
809
+ expect(updateResponse.body.data.name).to.eq(updateData.name)
810
+ expect(Number(updateResponse.body.data.amount)).to.eq(updateData.amount)
811
+ expect(Number(updateResponse.body.data.probability)).to.eq(updateData.probability)
812
+
813
+ cy.log(`3. Updated opportunity: ${updateResponse.body.data.name}`)
814
+
815
+ // 4. MOVE STAGE (to Proposal stage - index 1)
816
+ const proposalStageId = testPipeline.stages[1]?.id || testStageId
817
+
818
+ opportunityAPI.update(opportunityId, { stageId: proposalStageId }).then((stageResponse: any) => {
819
+ opportunityAPI.validateSuccessResponse(stageResponse, 200)
820
+ expect(stageResponse.body.data.stageId).to.eq(proposalStageId)
821
+
822
+ cy.log(`4. Moved to Proposal stage: ${proposalStageId}`)
823
+
824
+ // 5. INCREASE PROBABILITY (Negotiation stage)
825
+ opportunityAPI.update(opportunityId, {
826
+ probability: 75,
827
+ amount: 175000
828
+ }).then((probResponse: any) => {
829
+ opportunityAPI.validateSuccessResponse(probResponse, 200)
830
+ expect(Number(probResponse.body.data.probability)).to.eq(75)
831
+
832
+ cy.log(`5. Increased probability to 75% and amount to 175000`)
833
+
834
+ // 6. CLOSE AS WON
835
+ opportunityAPI.closeAsWon(opportunityId).then((wonResponse: any) => {
836
+ opportunityAPI.validateSuccessResponse(wonResponse, 200)
837
+ expect(wonResponse.body.data.status).to.eq('won')
838
+ expect(Number(wonResponse.body.data.probability)).to.eq(100)
839
+
840
+ cy.log(`6. Closed as WON`)
841
+
842
+ // 7. DELETE
843
+ opportunityAPI.delete(opportunityId).then((deleteResponse: any) => {
844
+ opportunityAPI.validateSuccessResponse(deleteResponse, 200)
845
+ expect(deleteResponse.body.data).to.have.property('success', true)
846
+
847
+ cy.log(`7. Deleted opportunity: ${opportunityId}`)
848
+
849
+ // 8. VERIFY DELETION
850
+ opportunityAPI.getById(opportunityId).then((verifyResponse: any) => {
851
+ expect(verifyResponse.status).to.eq(404)
852
+
853
+ cy.log('8. Verified deletion - opportunity no longer exists')
854
+ cy.log('Full CRUD lifecycle with stage moves and win tracking completed successfully!')
855
+ })
856
+ })
857
+ })
858
+ })
859
+ })
860
+ })
861
+ })
862
+ })
863
+ })
864
+ })
865
+ })