/**
* Safe Test Helper for production tests
*
* ⚠️ ONLY USE FOR MANUAL PRODUCTION TESTS
*
* This helper enforces safety rules for testing against real Attio API:
* - All records must have [TEST] prefix in name
* - Automatic tracking of created records
* - Cleanup in afterEach hooks
* - Configurable max records limit
*/
import { AttioClient } from '../../src/attio-client.js';
/**
* Safe test configuration from environment variables
*/
interface SafeTestConfig {
apiKey: string;
safeMode: boolean;
testPrefix: string;
maxTestRecords: number;
}
/**
* Record tracking entry
*/
interface TrackedRecord {
objectType: string;
recordId: string;
createdAt: Date;
}
/**
* Load safe test configuration from environment
*/
function loadSafeTestConfig(): SafeTestConfig {
const apiKey = process.env.ATTIO_API_KEY;
if (!apiKey) {
throw new Error('ATTIO_API_KEY environment variable is required for production tests');
}
return {
apiKey,
safeMode: process.env.ATTIO_SAFE_MODE === 'true',
testPrefix: process.env.ATTIO_TEST_PREFIX || '[TEST]',
maxTestRecords: parseInt(process.env.ATTIO_MAX_TEST_RECORDS || '100', 10),
};
}
/**
* Safe Test Helper for production tests
*
* @example
* ```typescript
* describe('Company CRUD', () => {
* const helper = new AttioSafeTestHelper();
*
* afterEach(async () => {
* await helper.cleanup(); // REQUIRED
* });
*
* it('creates a company', async () => {
* const company = await helper.createTestCompany({
* name: '[TEST] Acme Corp'
* });
* expect(company).toBeDefined();
* });
* });
* ```
*/
export class AttioSafeTestHelper {
private config: SafeTestConfig;
private client: AttioClient;
private trackedRecords: TrackedRecord[] = [];
constructor() {
this.config = loadSafeTestConfig();
this.client = new AttioClient({ apiKey: this.config.apiKey });
// Warn if safe mode is not enabled
if (!this.config.safeMode) {
console.warn(
'⚠️ WARNING: ATTIO_SAFE_MODE is not enabled. Set ATTIO_SAFE_MODE=true for additional safety checks.'
);
}
}
/**
* Get the Attio client (for direct API calls if needed)
*/
getClient(): AttioClient {
return this.client;
}
/**
* Create a test company with safety checks
*
* @param data - Company data (name MUST start with [TEST])
* @returns Created company record
*/
async createTestCompany(data: {
name: string;
domains?: string[];
description?: string;
[key: string]: any;
}): Promise<any> {
this.enforceTestPrefix(data.name, 'Company name');
this.checkRecordLimit();
const response = await this.client.post('/objects/companies/records', {
data: {
values: {
name: [{ value: data.name }],
...(data.domains && {
domains: data.domains.map((domain) => ({ domain_value: domain })),
}),
...(data.description && {
description: [{ value: data.description }],
}),
},
},
});
this.trackRecord('companies', (response as any).data.id.record_id);
return (response as any).data;
}
/**
* Create a test person with safety checks
*
* @param data - Person data (full name or first/last name MUST include [TEST])
* @returns Created person record
*/
async createTestPerson(data: {
firstName: string;
lastName: string;
emailAddresses?: string[];
jobTitle?: string;
[key: string]: any;
}): Promise<any> {
const fullName = `${data.firstName} ${data.lastName}`;
this.enforceTestPrefix(fullName, 'Person name');
this.checkRecordLimit();
const response = await this.client.post('/objects/people/records', {
data: {
values: {
name: [
{
first_name: data.firstName,
last_name: data.lastName,
full_name: fullName,
},
],
...(data.emailAddresses && {
email_addresses: data.emailAddresses.map((email) => ({
email_address: email,
})),
}),
...(data.jobTitle && {
job_title: [{ value: data.jobTitle }],
}),
},
},
});
this.trackRecord('people', (response as any).data.id.record_id);
return (response as any).data;
}
/**
* Delete a tracked record
*
* @param objectType - Object type (companies, people, etc.)
* @param recordId - Record ID to delete
*/
async deleteRecord(objectType: string, recordId: string): Promise<void> {
try {
await this.client.delete(`/objects/${objectType}/records/${recordId}`);
this.untrackRecord(objectType, recordId);
} catch (error: any) {
// Ignore 404 errors (record already deleted)
if (error.statusCode !== 404) {
console.error(`Failed to delete ${objectType} ${recordId}:`, error.message);
}
}
}
/**
* Cleanup all tracked records
*
* ⚠️ MUST be called in afterEach hook
*/
async cleanup(): Promise<void> {
const errors: Error[] = [];
console.log(`🧹 Cleaning up ${this.trackedRecords.length} test records...`);
for (const record of this.trackedRecords) {
try {
await this.client.delete(`/objects/${record.objectType}/records/${record.recordId}`);
} catch (error: any) {
// Ignore 404 errors (record already deleted)
if (error.statusCode !== 404) {
errors.push(error);
console.error(
`Failed to cleanup ${record.objectType} ${record.recordId}:`,
error.message
);
}
}
}
// Clear tracked records
this.trackedRecords = [];
// Report cleanup status
if (errors.length === 0) {
console.log('✅ Cleanup complete');
} else {
console.warn(`⚠️ Cleanup completed with ${errors.length} errors`);
}
}
/**
* Get list of tracked records (for debugging)
*/
getTrackedRecords(): TrackedRecord[] {
return [...this.trackedRecords];
}
/**
* Get count of tracked records by type
*/
getTrackedCount(objectType?: string): number {
if (objectType) {
return this.trackedRecords.filter((r) => r.objectType === objectType).length;
}
return this.trackedRecords.length;
}
// Private helpers
private enforceTestPrefix(value: string, fieldName: string): void {
if (!value.includes(this.config.testPrefix)) {
throw new Error(
`SAFETY: ${fieldName} must include "${this.config.testPrefix}" prefix. Got: "${value}"`
);
}
}
private checkRecordLimit(): void {
if (this.trackedRecords.length >= this.config.maxTestRecords) {
throw new Error(
`SAFETY: Maximum test records limit (${this.config.maxTestRecords}) reached. ` +
`Clean up existing records or increase ATTIO_MAX_TEST_RECORDS.`
);
}
}
private trackRecord(objectType: string, recordId: string): void {
this.trackedRecords.push({
objectType,
recordId,
createdAt: new Date(),
});
}
private untrackRecord(objectType: string, recordId: string): void {
this.trackedRecords = this.trackedRecords.filter(
(r) => !(r.objectType === objectType && r.recordId === recordId)
);
}
}