@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,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
+ }