@nextsparkjs/theme-crm 0.1.0-beta.19 → 0.1.0-beta.24
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/package.json +3 -3
- package/tests/cypress/e2e/api/activities/activities-crud.cy.ts +686 -0
- package/tests/cypress/e2e/api/campaigns/campaigns-crud.cy.ts +592 -0
- package/tests/cypress/e2e/api/companies/companies-crud.cy.ts +682 -0
- package/tests/cypress/e2e/api/contacts/contacts-crud.cy.ts +668 -0
- package/tests/cypress/e2e/api/leads/leads-crud.cy.ts +648 -0
- package/tests/cypress/e2e/api/notes/notes-crud.cy.ts +424 -0
- package/tests/cypress/e2e/api/opportunities/opportunities-crud.cy.ts +865 -0
- package/tests/cypress/e2e/api/pipelines/pipelines-crud.cy.ts +545 -0
- package/tests/cypress/e2e/api/products/products-crud.cy.ts +447 -0
- package/tests/cypress/e2e/ui/activities/activities-admin.cy.ts +268 -0
- package/tests/cypress/e2e/ui/activities/activities-member.cy.ts +257 -0
- package/tests/cypress/e2e/ui/activities/activities-owner.cy.ts +268 -0
- package/tests/cypress/e2e/ui/companies/companies-admin.cy.ts +188 -0
- package/tests/cypress/e2e/ui/companies/companies-member.cy.ts +166 -0
- package/tests/cypress/e2e/ui/companies/companies-owner.cy.ts +189 -0
- package/tests/cypress/e2e/ui/contacts/contacts-admin.cy.ts +252 -0
- package/tests/cypress/e2e/ui/contacts/contacts-member.cy.ts +224 -0
- package/tests/cypress/e2e/ui/contacts/contacts-owner.cy.ts +236 -0
- package/tests/cypress/e2e/ui/leads/leads-admin.cy.ts +286 -0
- package/tests/cypress/e2e/ui/leads/leads-member.cy.ts +193 -0
- package/tests/cypress/e2e/ui/leads/leads-owner.cy.ts +210 -0
- package/tests/cypress/e2e/ui/opportunities/opportunities-admin.cy.ts +197 -0
- package/tests/cypress/e2e/ui/opportunities/opportunities-member.cy.ts +229 -0
- package/tests/cypress/e2e/ui/opportunities/opportunities-owner.cy.ts +196 -0
- package/tests/cypress/e2e/ui/pipelines/pipelines-admin.cy.ts +320 -0
- package/tests/cypress/e2e/ui/pipelines/pipelines-member.cy.ts +262 -0
- package/tests/cypress/e2e/ui/pipelines/pipelines-owner.cy.ts +282 -0
- package/tests/cypress/fixtures/blocks.json +9 -0
- package/tests/cypress/fixtures/entities.json +240 -0
- package/tests/cypress/src/components/CRMDataTable.js +223 -0
- package/tests/cypress/src/components/CRMMobileNav.js +138 -0
- package/tests/cypress/src/components/CRMSidebar.js +145 -0
- package/tests/cypress/src/components/CRMTopBar.js +194 -0
- package/tests/cypress/src/components/DealCard.js +197 -0
- package/tests/cypress/src/components/EntityDetail.ts +290 -0
- package/tests/cypress/src/components/EntityForm.ts +357 -0
- package/tests/cypress/src/components/EntityList.ts +360 -0
- package/tests/cypress/src/components/PipelineKanban.js +204 -0
- package/tests/cypress/src/components/StageColumn.js +196 -0
- package/tests/cypress/src/components/index.js +13 -0
- package/tests/cypress/src/components/index.ts +22 -0
- package/tests/cypress/src/controllers/ActivityAPIController.ts +113 -0
- package/tests/cypress/src/controllers/BaseAPIController.ts +307 -0
- package/tests/cypress/src/controllers/CampaignAPIController.ts +114 -0
- package/tests/cypress/src/controllers/CompanyAPIController.ts +112 -0
- package/tests/cypress/src/controllers/ContactAPIController.ts +104 -0
- package/tests/cypress/src/controllers/LeadAPIController.ts +96 -0
- package/tests/cypress/src/controllers/NoteAPIController.ts +130 -0
- package/tests/cypress/src/controllers/OpportunityAPIController.ts +134 -0
- package/tests/cypress/src/controllers/PipelineAPIController.ts +116 -0
- package/tests/cypress/src/controllers/ProductAPIController.ts +113 -0
- package/tests/cypress/src/controllers/index.ts +35 -0
- package/tests/cypress/src/entities/ActivitiesPOM.ts +130 -0
- package/tests/cypress/src/entities/CompaniesPOM.ts +117 -0
- package/tests/cypress/src/entities/ContactsPOM.ts +117 -0
- package/tests/cypress/src/entities/LeadsPOM.ts +129 -0
- package/tests/cypress/src/entities/OpportunitiesPOM.ts +178 -0
- package/tests/cypress/src/entities/PipelinesPOM.ts +341 -0
- package/tests/cypress/src/entities/index.ts +31 -0
- package/tests/cypress/src/forms/OpportunityForm.js +316 -0
- package/tests/cypress/src/forms/PipelineForm.js +243 -0
- package/tests/cypress/src/forms/index.js +8 -0
- package/tests/cypress/src/index.js +22 -0
- package/tests/cypress/src/index.ts +68 -0
- package/tests/cypress/src/selectors.ts +50 -0
- package/tests/cypress/src/session-helpers.ts +94 -0
- package/tests/cypress/support/e2e.ts +89 -0
- package/tests/cypress.config.ts +165 -0
- package/tests/jest/__mocks__/jose.js +22 -0
- package/tests/jest/__mocks__/next-server.js +56 -0
- package/tests/jest/jest.config.cjs +127 -0
- package/tests/jest/setup.ts +170 -0
- package/tests/tsconfig.json +15 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StageColumn - Page Object Model Class
|
|
3
|
+
*
|
|
4
|
+
* POM for individual stage columns in the pipeline kanban board.
|
|
5
|
+
* Handles stage-specific interactions and validations.
|
|
6
|
+
*/
|
|
7
|
+
export class StageColumn {
|
|
8
|
+
/**
|
|
9
|
+
* Get selectors for a specific stage
|
|
10
|
+
* @param {string} stageId - Stage ID
|
|
11
|
+
*/
|
|
12
|
+
static getStageSelectors(stageId) {
|
|
13
|
+
return {
|
|
14
|
+
column: `[data-cy="pipeline-stage-${stageId}"]`,
|
|
15
|
+
header: `[data-cy="stage-header-${stageId}"]`,
|
|
16
|
+
title: `[data-cy="stage-title-${stageId}"]`,
|
|
17
|
+
count: `[data-cy="stage-count-${stageId}"]`,
|
|
18
|
+
value: `[data-cy="stage-value-${stageId}"]`,
|
|
19
|
+
deals: `[data-cy="stage-deals-${stageId}"]`,
|
|
20
|
+
emptyState: `[data-cy="stage-empty-${stageId}"]`,
|
|
21
|
+
addDealBtn: `[data-cy="stage-add-deal-${stageId}"]`,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} stageId - The stage ID
|
|
27
|
+
*/
|
|
28
|
+
constructor(stageId) {
|
|
29
|
+
this.stageId = stageId
|
|
30
|
+
this.selectors = StageColumn.getStageSelectors(stageId)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate stage column is visible
|
|
35
|
+
*/
|
|
36
|
+
validateVisible() {
|
|
37
|
+
cy.get(this.selectors.column).should('be.visible')
|
|
38
|
+
return this
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate stage header is visible
|
|
43
|
+
*/
|
|
44
|
+
validateHeaderVisible() {
|
|
45
|
+
cy.get(this.selectors.header).should('be.visible')
|
|
46
|
+
return this
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate stage title
|
|
51
|
+
* @param {string} title - Expected title
|
|
52
|
+
*/
|
|
53
|
+
validateTitle(title) {
|
|
54
|
+
cy.get(this.selectors.title).should('contain', title)
|
|
55
|
+
return this
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get the deal count in this stage
|
|
60
|
+
*/
|
|
61
|
+
getDealCount() {
|
|
62
|
+
return cy.get(this.selectors.column)
|
|
63
|
+
.find('[data-cy^="deal-card-"]')
|
|
64
|
+
.its('length')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate number of deals in this stage
|
|
69
|
+
* @param {number} count - Expected deal count
|
|
70
|
+
*/
|
|
71
|
+
validateDealCount(count) {
|
|
72
|
+
if (count === 0) {
|
|
73
|
+
this.validateEmpty()
|
|
74
|
+
} else {
|
|
75
|
+
cy.get(this.selectors.column)
|
|
76
|
+
.find('[data-cy^="deal-card-"]')
|
|
77
|
+
.should('have.length', count)
|
|
78
|
+
}
|
|
79
|
+
return this
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validate stage count badge
|
|
84
|
+
* @param {number} count - Expected count
|
|
85
|
+
*/
|
|
86
|
+
validateCountBadge(count) {
|
|
87
|
+
cy.get(this.selectors.count).should('contain', count)
|
|
88
|
+
return this
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the total value in this stage
|
|
93
|
+
*/
|
|
94
|
+
getTotalValue() {
|
|
95
|
+
return cy.get(this.selectors.value).invoke('text')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Validate total value displayed
|
|
100
|
+
* @param {string} value - Expected value (e.g., "$50,000")
|
|
101
|
+
*/
|
|
102
|
+
validateTotalValue(value) {
|
|
103
|
+
cy.get(this.selectors.value).should('contain', value)
|
|
104
|
+
return this
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Validate stage is empty
|
|
109
|
+
*/
|
|
110
|
+
isEmpty() {
|
|
111
|
+
return cy.get(this.selectors.emptyState).should('exist')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validate empty state is visible
|
|
116
|
+
*/
|
|
117
|
+
validateEmpty() {
|
|
118
|
+
cy.get(this.selectors.emptyState).should('be.visible')
|
|
119
|
+
return this
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validate stage is not empty
|
|
124
|
+
*/
|
|
125
|
+
validateNotEmpty() {
|
|
126
|
+
cy.get(this.selectors.emptyState).should('not.exist')
|
|
127
|
+
cy.get(this.selectors.column)
|
|
128
|
+
.find('[data-cy^="deal-card-"]')
|
|
129
|
+
.should('have.length.greaterThan', 0)
|
|
130
|
+
return this
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Click add deal button in this stage
|
|
135
|
+
*/
|
|
136
|
+
addDeal() {
|
|
137
|
+
cy.get(this.selectors.addDealBtn).click()
|
|
138
|
+
return this
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate add deal button is visible
|
|
143
|
+
*/
|
|
144
|
+
validateAddDealVisible() {
|
|
145
|
+
cy.get(this.selectors.addDealBtn).should('be.visible')
|
|
146
|
+
return this
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get all deals in this stage
|
|
151
|
+
*/
|
|
152
|
+
getDeals() {
|
|
153
|
+
return cy.get(this.selectors.column)
|
|
154
|
+
.find('[data-cy^="deal-card-"]')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate a specific deal exists in this stage
|
|
159
|
+
* @param {string} dealId - Deal ID
|
|
160
|
+
*/
|
|
161
|
+
validateDealExists(dealId) {
|
|
162
|
+
cy.get(this.selectors.column)
|
|
163
|
+
.find(`[data-cy="deal-card-${dealId}"]`)
|
|
164
|
+
.should('exist')
|
|
165
|
+
return this
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Validate a specific deal does not exist in this stage
|
|
170
|
+
* @param {string} dealId - Deal ID
|
|
171
|
+
*/
|
|
172
|
+
validateDealNotExists(dealId) {
|
|
173
|
+
cy.get(this.selectors.column)
|
|
174
|
+
.find(`[data-cy="deal-card-${dealId}"]`)
|
|
175
|
+
.should('not.exist')
|
|
176
|
+
return this
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get a specific deal card in this stage
|
|
181
|
+
* @param {string} dealId - Deal ID
|
|
182
|
+
*/
|
|
183
|
+
getDealCard(dealId) {
|
|
184
|
+
return cy.get(this.selectors.column)
|
|
185
|
+
.find(`[data-cy="deal-card-${dealId}"]`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Click a specific deal card in this stage
|
|
190
|
+
* @param {string} dealId - Deal ID
|
|
191
|
+
*/
|
|
192
|
+
clickDeal(dealId) {
|
|
193
|
+
this.getDealCard(dealId).click()
|
|
194
|
+
return this
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRM Theme Components - Export Index
|
|
3
|
+
*
|
|
4
|
+
* Exports all CRM theme component POM classes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { CRMSidebar } from './CRMSidebar.js'
|
|
8
|
+
export { CRMTopBar } from './CRMTopBar.js'
|
|
9
|
+
export { CRMMobileNav } from './CRMMobileNav.js'
|
|
10
|
+
export { CRMDataTable } from './CRMDataTable.js'
|
|
11
|
+
export { PipelineKanban } from './PipelineKanban.js'
|
|
12
|
+
export { StageColumn } from './StageColumn.js'
|
|
13
|
+
export { DealCard } from './DealCard.js'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRM Theme - Cypress POM Components
|
|
3
|
+
*
|
|
4
|
+
* Export all generic POM classes for CRM entity testing.
|
|
5
|
+
* These POMs use data-cy selectors from entities.json following
|
|
6
|
+
* the convention: {slug}-{component}-{detail}
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { EntityList, type EntityConfig } from './EntityList'
|
|
10
|
+
export { EntityForm } from './EntityForm'
|
|
11
|
+
export { EntityDetail, type EntityDetailConfig } from './EntityDetail'
|
|
12
|
+
|
|
13
|
+
// Default exports for convenience
|
|
14
|
+
import { EntityList } from './EntityList'
|
|
15
|
+
import { EntityForm } from './EntityForm'
|
|
16
|
+
import { EntityDetail } from './EntityDetail'
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
EntityList,
|
|
20
|
+
EntityForm,
|
|
21
|
+
EntityDetail,
|
|
22
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActivityAPIController - TypeScript controller for Activities API
|
|
3
|
+
*
|
|
4
|
+
* Handles CRUD operations for /api/v1/activities endpoints
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { BaseAPIController, APIRequestOptions, APIResponse } from './BaseAPIController'
|
|
8
|
+
|
|
9
|
+
export interface ActivityData {
|
|
10
|
+
type?: string
|
|
11
|
+
subject?: string
|
|
12
|
+
description?: string
|
|
13
|
+
dueDate?: string
|
|
14
|
+
contactId?: string
|
|
15
|
+
companyId?: string
|
|
16
|
+
opportunityId?: string
|
|
17
|
+
completedAt?: string
|
|
18
|
+
assignedTo?: string
|
|
19
|
+
status?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ActivityGetAllOptions extends APIRequestOptions {
|
|
23
|
+
type?: string
|
|
24
|
+
status?: string
|
|
25
|
+
contactId?: string
|
|
26
|
+
companyId?: string
|
|
27
|
+
opportunityId?: string
|
|
28
|
+
assignedTo?: string
|
|
29
|
+
completed?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class ActivityAPIController extends BaseAPIController {
|
|
33
|
+
protected entitySlug = 'activities'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* GET all activities with filtering options
|
|
37
|
+
*/
|
|
38
|
+
getAll(options: ActivityGetAllOptions = {}): Cypress.Chainable<APIResponse> {
|
|
39
|
+
return super.getAll(options)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mark activity as complete
|
|
44
|
+
*/
|
|
45
|
+
complete(id: string, options: APIRequestOptions = {}): Cypress.Chainable<APIResponse> {
|
|
46
|
+
const completedAt = new Date().toISOString()
|
|
47
|
+
return this.update(id, { completedAt }, options)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reschedule activity to new date
|
|
52
|
+
*/
|
|
53
|
+
reschedule(id: string, newDate: string, options: APIRequestOptions = {}): Cypress.Chainable<APIResponse> {
|
|
54
|
+
return this.update(id, { dueDate: newDate }, options)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate random activity data for testing
|
|
59
|
+
*/
|
|
60
|
+
generateRandomData(overrides: Partial<ActivityData> = {}): ActivityData {
|
|
61
|
+
const timestamp = Date.now()
|
|
62
|
+
const randomId = Math.random().toString(36).substring(2, 8)
|
|
63
|
+
|
|
64
|
+
const types = ['call', 'email', 'meeting', 'task', 'note', 'demo', 'presentation']
|
|
65
|
+
const subjects = [
|
|
66
|
+
'Initial Discovery Call',
|
|
67
|
+
'Product Demo',
|
|
68
|
+
'Follow-up Email',
|
|
69
|
+
'Contract Review Meeting',
|
|
70
|
+
'Proposal Presentation',
|
|
71
|
+
'Stakeholder Introduction'
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
const daysFromNow = Math.floor(Math.random() * 30) + 1
|
|
75
|
+
const dueDate = new Date()
|
|
76
|
+
dueDate.setDate(dueDate.getDate() + daysFromNow)
|
|
77
|
+
|
|
78
|
+
const type = types[Math.floor(Math.random() * types.length)]
|
|
79
|
+
const subject = subjects[Math.floor(Math.random() * subjects.length)]
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
type,
|
|
83
|
+
subject: `${subject} - ${randomId}`,
|
|
84
|
+
description: `Test activity created at ${new Date(timestamp).toISOString()}`,
|
|
85
|
+
dueDate: dueDate.toISOString(),
|
|
86
|
+
...overrides
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate activity object structure
|
|
92
|
+
*/
|
|
93
|
+
validateObject(activity: Record<string, unknown>, allowMetas = false): void {
|
|
94
|
+
this.validateSystemFields(activity)
|
|
95
|
+
|
|
96
|
+
expect(activity).to.have.property('type')
|
|
97
|
+
expect(activity.type).to.be.a('string')
|
|
98
|
+
|
|
99
|
+
expect(activity).to.have.property('subject')
|
|
100
|
+
expect(activity.subject).to.be.a('string')
|
|
101
|
+
|
|
102
|
+
this.validateOptionalStringFields(activity, [
|
|
103
|
+
'description', 'dueDate', 'contactId', 'companyId',
|
|
104
|
+
'opportunityId', 'completedAt', 'assignedTo'
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
if (allowMetas && Object.prototype.hasOwnProperty.call(activity, 'metas')) {
|
|
108
|
+
expect(activity.metas).to.be.an('object')
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default ActivityAPIController
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BaseAPIController - Abstract base class for all CRM API controllers
|
|
3
|
+
*
|
|
4
|
+
* Provides common functionality for:
|
|
5
|
+
* - HTTP request handling with authentication
|
|
6
|
+
* - Response validation
|
|
7
|
+
* - Test data generation
|
|
8
|
+
* - Error handling
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface APIRequestOptions {
|
|
12
|
+
headers?: Record<string, string>
|
|
13
|
+
page?: number
|
|
14
|
+
limit?: number
|
|
15
|
+
search?: string
|
|
16
|
+
metas?: string
|
|
17
|
+
[key: string]: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface APIResponse<T = unknown> {
|
|
21
|
+
status: number
|
|
22
|
+
body: {
|
|
23
|
+
success: boolean
|
|
24
|
+
data: T
|
|
25
|
+
info: {
|
|
26
|
+
page?: number
|
|
27
|
+
limit?: number
|
|
28
|
+
total?: number | string
|
|
29
|
+
totalPages?: number
|
|
30
|
+
timestamp: string
|
|
31
|
+
}
|
|
32
|
+
error?: string
|
|
33
|
+
code?: string
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CreateTestRecordOptions {
|
|
38
|
+
withRetry?: boolean
|
|
39
|
+
maxRetries?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export abstract class BaseAPIController {
|
|
43
|
+
protected baseUrl: string
|
|
44
|
+
protected apiKey: string | null
|
|
45
|
+
protected teamId: string | null
|
|
46
|
+
protected abstract entitySlug: string
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
baseUrl = 'http://localhost:5173',
|
|
50
|
+
apiKey: string | null = null,
|
|
51
|
+
teamId: string | null = null
|
|
52
|
+
) {
|
|
53
|
+
this.baseUrl = baseUrl
|
|
54
|
+
this.apiKey = apiKey
|
|
55
|
+
this.teamId = teamId
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get the API endpoint for this entity
|
|
60
|
+
*/
|
|
61
|
+
protected get endpoint(): string {
|
|
62
|
+
return `/api/v1/${this.entitySlug}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get endpoint for a specific record by ID
|
|
67
|
+
*/
|
|
68
|
+
protected endpointById(id: string): string {
|
|
69
|
+
return `${this.endpoint}/${id}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Set the API key for requests
|
|
74
|
+
*/
|
|
75
|
+
setApiKey(apiKey: string): this {
|
|
76
|
+
this.apiKey = apiKey
|
|
77
|
+
return this
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Set the team ID for requests
|
|
82
|
+
*/
|
|
83
|
+
setTeamId(teamId: string): this {
|
|
84
|
+
this.teamId = teamId
|
|
85
|
+
return this
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get default headers for requests
|
|
90
|
+
*/
|
|
91
|
+
protected getHeaders(additionalHeaders: Record<string, string> = {}): Record<string, string> {
|
|
92
|
+
const headers: Record<string, string> = {
|
|
93
|
+
'Content-Type': 'application/json',
|
|
94
|
+
...additionalHeaders
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (this.apiKey) {
|
|
98
|
+
headers['Authorization'] = `Bearer ${this.apiKey}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (this.teamId) {
|
|
102
|
+
headers['x-team-id'] = this.teamId
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return headers
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build query string from options
|
|
110
|
+
*/
|
|
111
|
+
protected buildQueryString(options: Record<string, unknown>): string {
|
|
112
|
+
const queryParams = new URLSearchParams()
|
|
113
|
+
|
|
114
|
+
Object.entries(options).forEach(([key, value]) => {
|
|
115
|
+
if (value !== undefined && value !== null && key !== 'headers') {
|
|
116
|
+
queryParams.append(key, String(value))
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const queryString = queryParams.toString()
|
|
121
|
+
return queryString ? `?${queryString}` : ''
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* GET all records with optional filtering
|
|
126
|
+
*/
|
|
127
|
+
getAll(options: APIRequestOptions = {}): Cypress.Chainable<APIResponse> {
|
|
128
|
+
const { headers = {}, ...queryOptions } = options
|
|
129
|
+
const url = `${this.baseUrl}${this.endpoint}${this.buildQueryString(queryOptions)}`
|
|
130
|
+
|
|
131
|
+
return cy.request({
|
|
132
|
+
method: 'GET',
|
|
133
|
+
url,
|
|
134
|
+
headers: this.getHeaders(headers),
|
|
135
|
+
failOnStatusCode: false
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* GET a single record by ID
|
|
141
|
+
*/
|
|
142
|
+
getById(id: string, options: APIRequestOptions = {}): Cypress.Chainable<APIResponse> {
|
|
143
|
+
const { headers = {}, metas } = options
|
|
144
|
+
const queryParams: Record<string, unknown> = {}
|
|
145
|
+
if (metas) queryParams.metas = metas
|
|
146
|
+
|
|
147
|
+
const url = `${this.baseUrl}${this.endpointById(id)}${this.buildQueryString(queryParams)}`
|
|
148
|
+
|
|
149
|
+
return cy.request({
|
|
150
|
+
method: 'GET',
|
|
151
|
+
url,
|
|
152
|
+
headers: this.getHeaders(headers),
|
|
153
|
+
failOnStatusCode: false
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* POST create a new record
|
|
159
|
+
*/
|
|
160
|
+
create(data: Record<string, unknown>, options: APIRequestOptions = {}): Cypress.Chainable<APIResponse> {
|
|
161
|
+
const { headers = {} } = options
|
|
162
|
+
|
|
163
|
+
return cy.request({
|
|
164
|
+
method: 'POST',
|
|
165
|
+
url: `${this.baseUrl}${this.endpoint}`,
|
|
166
|
+
headers: this.getHeaders(headers),
|
|
167
|
+
body: data,
|
|
168
|
+
failOnStatusCode: false
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* PATCH update an existing record
|
|
174
|
+
*/
|
|
175
|
+
update(id: string, data: Record<string, unknown>, options: APIRequestOptions = {}): Cypress.Chainable<APIResponse> {
|
|
176
|
+
const { headers = {} } = options
|
|
177
|
+
|
|
178
|
+
return cy.request({
|
|
179
|
+
method: 'PATCH',
|
|
180
|
+
url: `${this.baseUrl}${this.endpointById(id)}`,
|
|
181
|
+
headers: this.getHeaders(headers),
|
|
182
|
+
body: data,
|
|
183
|
+
failOnStatusCode: false
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* DELETE a record
|
|
189
|
+
*/
|
|
190
|
+
delete(id: string, options: APIRequestOptions = {}): Cypress.Chainable<APIResponse> {
|
|
191
|
+
const { headers = {} } = options
|
|
192
|
+
|
|
193
|
+
return cy.request({
|
|
194
|
+
method: 'DELETE',
|
|
195
|
+
url: `${this.baseUrl}${this.endpointById(id)}`,
|
|
196
|
+
headers: this.getHeaders(headers),
|
|
197
|
+
failOnStatusCode: false
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Generate random test data - must be implemented by subclasses
|
|
203
|
+
*/
|
|
204
|
+
abstract generateRandomData(overrides?: Record<string, unknown>): Record<string, unknown>
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Create a test record with optional retry logic
|
|
208
|
+
*/
|
|
209
|
+
createTestRecord(
|
|
210
|
+
data: Record<string, unknown> = {},
|
|
211
|
+
options: CreateTestRecordOptions = {}
|
|
212
|
+
): Cypress.Chainable<Record<string, unknown>> {
|
|
213
|
+
const { withRetry = false, maxRetries = 3 } = options
|
|
214
|
+
const testData = this.generateRandomData(data)
|
|
215
|
+
|
|
216
|
+
if (withRetry) {
|
|
217
|
+
return this.createWithRetry(testData, maxRetries)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return this.create(testData).then((response) => {
|
|
221
|
+
if (response.status === 201) {
|
|
222
|
+
return { ...testData, ...response.body.data }
|
|
223
|
+
}
|
|
224
|
+
throw new Error(`Failed to create test record: ${response.body?.error || 'Unknown error'}`)
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Create with retry logic for handling transient failures
|
|
230
|
+
*/
|
|
231
|
+
protected createWithRetry(
|
|
232
|
+
data: Record<string, unknown>,
|
|
233
|
+
maxRetries: number,
|
|
234
|
+
currentAttempt = 1
|
|
235
|
+
): Cypress.Chainable<Record<string, unknown>> {
|
|
236
|
+
return this.create(data).then((response) => {
|
|
237
|
+
if (response.status === 201) {
|
|
238
|
+
return { ...data, ...response.body.data }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (response.status === 500 && currentAttempt < maxRetries) {
|
|
242
|
+
cy.wait(500 * currentAttempt) // Exponential backoff
|
|
243
|
+
return this.createWithRetry(data, maxRetries, currentAttempt + 1)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
throw new Error(`Failed to create test record after ${currentAttempt} attempts: ${response.body?.error || 'Unknown error'}`)
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Clean up a test record
|
|
252
|
+
*/
|
|
253
|
+
cleanupTestRecord(id: string): Cypress.Chainable<APIResponse> {
|
|
254
|
+
return this.delete(id)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ========== VALIDATION METHODS ==========
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Validate success response structure
|
|
261
|
+
*/
|
|
262
|
+
validateSuccessResponse(response: APIResponse, expectedStatus = 200): void {
|
|
263
|
+
expect(response.status).to.eq(expectedStatus)
|
|
264
|
+
expect(response.body).to.have.property('success', true)
|
|
265
|
+
expect(response.body).to.have.property('data')
|
|
266
|
+
expect(response.body).to.have.property('info')
|
|
267
|
+
expect(response.body.info).to.have.property('timestamp')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Validate error response structure
|
|
272
|
+
*/
|
|
273
|
+
validateErrorResponse(response: APIResponse, expectedStatus: number, expectedErrorCode?: string): void {
|
|
274
|
+
expect(response.status).to.eq(expectedStatus)
|
|
275
|
+
expect(response.body).to.have.property('success', false)
|
|
276
|
+
expect(response.body).to.have.property('error')
|
|
277
|
+
|
|
278
|
+
if (expectedErrorCode) {
|
|
279
|
+
expect(response.body).to.have.property('code', expectedErrorCode)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Validate basic object structure - must be implemented by subclasses
|
|
285
|
+
*/
|
|
286
|
+
abstract validateObject(obj: Record<string, unknown>, allowMetas?: boolean): void
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Validate common system fields present in all entities
|
|
290
|
+
*/
|
|
291
|
+
protected validateSystemFields(obj: Record<string, unknown>): void {
|
|
292
|
+
expect(obj).to.have.property('id')
|
|
293
|
+
expect(obj).to.have.property('createdAt')
|
|
294
|
+
expect(obj).to.have.property('updatedAt')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Helper to validate optional string fields
|
|
299
|
+
*/
|
|
300
|
+
protected validateOptionalStringFields(obj: Record<string, unknown>, fields: string[]): void {
|
|
301
|
+
fields.forEach(field => {
|
|
302
|
+
if (obj[field] !== null && obj[field] !== undefined) {
|
|
303
|
+
expect(obj[field]).to.be.a('string')
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
}
|