/**
* Tests for schema cache
*
* The schema cache is a singleton that caches workspace schema for 1 hour.
* These tests verify caching behavior, TTL expiration, and force reload.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
getWorkspaceSchema,
clearSchemaCache,
filterToKeyAttributes,
type WorkspaceSchema,
} from '../../../src/lib/schema-cache.js';
import * as attioClient from '../../../src/attio-client.js';
// Mock the attio-client module
vi.mock('../../../src/attio-client.js', () => ({
createAttioClient: vi.fn(),
}));
// Sample schema data for testing
const mockSchema: WorkspaceSchema = {
generated_at: '2024-11-15T00:00:00Z',
version: '1.0.0',
objects: {
companies: {
api_slug: 'companies',
title: 'Companies',
description: 'Company records',
type: 'object',
attributes: [
{
slug: 'record_id',
title: 'Record ID',
type: 'text',
required: false,
description: 'Unique identifier for this record',
},
{
slug: 'name',
title: 'Name',
type: 'text',
required: true,
},
{
slug: 'domains',
title: 'Domains',
type: 'domain',
required: false,
is_array: true,
},
{
slug: 'description',
title: 'Description',
type: 'text',
required: false,
},
{
slug: 'internal_notes',
title: 'Internal Notes',
type: 'text',
required: false,
},
{
slug: 'pipeline_stage',
title: 'Pipeline Stage',
type: 'status',
required: false,
statuses: ['Lead', 'Active', 'Passed'],
},
],
},
},
};
describe('getWorkspaceSchema - Caching Behavior', () => {
let mockClient: any;
let objectsCallCount: number;
beforeEach(() => {
// Clear cache before each test
clearSchemaCache();
// Reset call counter
objectsCallCount = 0;
// Create mock client
mockClient = {
get: vi.fn(async (path: string) => {
if (path === '/objects') {
objectsCallCount++;
return {
data: [
{
api_slug: 'companies',
name: 'Company',
plural_noun: 'Companies',
type: 'object',
},
],
};
}
if (path === '/objects/companies/attributes') {
return {
data: mockSchema.objects.companies.attributes.map((attr) => ({
api_slug: attr.slug,
title: attr.title,
type: attr.type,
is_required: attr.required,
is_multiselect: attr.is_array,
})),
};
}
return { data: [] };
}),
};
// Mock createAttioClient to return our mock client
vi.mocked(attioClient.createAttioClient).mockReturnValue(mockClient);
});
it('should fetch schema from API on first call', async () => {
const schema = await getWorkspaceSchema('test-key');
expect(schema).toBeDefined();
expect(schema.objects.companies).toBeDefined();
expect(objectsCallCount).toBe(1); // API was called once
});
it('should use cached schema on second call (within TTL)', async () => {
// First call
const schema1 = await getWorkspaceSchema('test-key');
expect(objectsCallCount).toBe(1);
// Second call (should use cache)
const schema2 = await getWorkspaceSchema('test-key');
expect(objectsCallCount).toBe(1); // Still 1, not 2!
expect(schema2).toEqual(schema1);
});
it('should bypass cache when forceReload is true', async () => {
// First call
await getWorkspaceSchema('test-key');
expect(objectsCallCount).toBe(1);
// Second call with forceReload
await getWorkspaceSchema('test-key', true);
expect(objectsCallCount).toBe(2); // API called again!
});
it('should refetch after cache is cleared', async () => {
// First call
await getWorkspaceSchema('test-key');
expect(objectsCallCount).toBe(1);
// Clear cache
clearSchemaCache();
// Second call (should refetch)
await getWorkspaceSchema('test-key');
expect(objectsCallCount).toBe(2);
});
it('should include generated_at timestamp', async () => {
const schema = await getWorkspaceSchema('test-key');
expect(schema.generated_at).toBeDefined();
expect(typeof schema.generated_at).toBe('string');
// Should be valid ISO date
expect(new Date(schema.generated_at).toISOString()).toBe(schema.generated_at);
});
});
describe('filterToKeyAttributes', () => {
it('should filter to only key attributes', () => {
const filtered = filterToKeyAttributes(mockSchema);
const companyAttrs = filtered.objects.companies.attributes;
// Should include name (required)
expect(companyAttrs.find((a) => a.slug === 'name')).toBeDefined();
// Should include domains (contact field)
expect(companyAttrs.find((a) => a.slug === 'domains')).toBeDefined();
// Should include description
expect(companyAttrs.find((a) => a.slug === 'description')).toBeDefined();
// Should include pipeline_stage (status field)
expect(companyAttrs.find((a) => a.slug === 'pipeline_stage')).toBeDefined();
// Should NOT include record_id (system field)
expect(companyAttrs.find((a) => a.slug === 'record_id')).toBeUndefined();
// Should NOT include internal_notes (not a key field)
expect(companyAttrs.find((a) => a.slug === 'internal_notes')).toBeUndefined();
});
it('should add total_attributes count', () => {
const filtered = filterToKeyAttributes(mockSchema);
expect(filtered.objects.companies.total_attributes).toBe(6);
});
it('should mark as showing key attributes', () => {
const filtered = filterToKeyAttributes(mockSchema);
expect(filtered.objects.companies.showing).toBe('key');
});
it('should set view to summary', () => {
const filtered = filterToKeyAttributes(mockSchema);
expect(filtered.view).toBe('summary');
});
it('should preserve original schema structure', () => {
const filtered = filterToKeyAttributes(mockSchema);
expect(filtered.generated_at).toBe(mockSchema.generated_at);
expect(filtered.version).toBe(mockSchema.version);
expect(filtered.objects.companies.api_slug).toBe('companies');
});
});
describe('filterToKeyAttributes - Attribute Rules', () => {
const testSchema: WorkspaceSchema = {
generated_at: '2024-11-15T00:00:00Z',
version: '1.0.0',
objects: {
test: {
api_slug: 'test',
title: 'Test',
description: 'Test object',
type: 'object',
attributes: [
// System fields (EXCLUDE)
{ slug: 'record_id', title: 'ID', type: 'text', required: false },
{ slug: 'created_at', title: 'Created', type: 'timestamp', required: false },
// Required fields (INCLUDE)
{ slug: 'name', title: 'Name', type: 'text', required: true },
// Select/status fields (INCLUDE)
{
slug: 'stage',
title: 'Stage',
type: 'status',
required: false,
statuses: ['Active'],
},
// Relationships (INCLUDE)
{
slug: 'company_id',
title: 'Company',
type: 'record-reference',
required: false,
},
// Contact fields (INCLUDE)
{
slug: 'email_addresses',
title: 'Emails',
type: 'email',
required: false,
is_array: true,
},
// Business metrics (INCLUDE)
{
slug: 'target_amount',
title: 'Target Amount',
type: 'currency',
required: false,
},
{
slug: 'vintage_year',
title: 'Vintage Year',
type: 'number',
required: false,
},
// Regular optional field (EXCLUDE)
{ slug: 'random_field', title: 'Random', type: 'text', required: false },
],
},
},
};
it('should exclude system fields', () => {
const filtered = filterToKeyAttributes(testSchema);
const attrs = filtered.objects.test.attributes;
expect(attrs.find((a) => a.slug === 'record_id')).toBeUndefined();
expect(attrs.find((a) => a.slug === 'created_at')).toBeUndefined();
});
it('should include required fields', () => {
const filtered = filterToKeyAttributes(testSchema);
const attrs = filtered.objects.test.attributes;
expect(attrs.find((a) => a.slug === 'name')).toBeDefined();
});
it('should include select and status fields', () => {
const filtered = filterToKeyAttributes(testSchema);
const attrs = filtered.objects.test.attributes;
expect(attrs.find((a) => a.slug === 'stage')).toBeDefined();
});
it('should include relationships', () => {
const filtered = filterToKeyAttributes(testSchema);
const attrs = filtered.objects.test.attributes;
expect(attrs.find((a) => a.slug === 'company_id')).toBeDefined();
});
it('should include contact fields', () => {
const filtered = filterToKeyAttributes(testSchema);
const attrs = filtered.objects.test.attributes;
expect(attrs.find((a) => a.slug === 'email_addresses')).toBeDefined();
});
it('should include business metrics', () => {
const filtered = filterToKeyAttributes(testSchema);
const attrs = filtered.objects.test.attributes;
expect(attrs.find((a) => a.slug === 'target_amount')).toBeDefined();
expect(attrs.find((a) => a.slug === 'vintage_year')).toBeDefined();
});
it('should exclude regular optional fields', () => {
const filtered = filterToKeyAttributes(testSchema);
const attrs = filtered.objects.test.attributes;
expect(attrs.find((a) => a.slug === 'random_field')).toBeUndefined();
});
});