Files
google-drive-content-adapter/tests/contract/sitemap-schema.test.js

228 lines
8.6 KiB
JavaScript

/**
* Contract Tests: /sitemap.xml XML Schema Validation
*
* Tests T020-T023: Verify API contract compliance for sitemap endpoint
* Reference: specs/001-drive-proxy-adapter/contracts/sitemap-xml-schema.md
*
* @module tests/contract/sitemap-schema
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
// =============================================================================
// T020: Contract test for /sitemap.xml success response (200 OK)
// =============================================================================
describe('T020: /sitemap.xml Success Response Contract', () => {
it('should return 200 OK with valid XML structure', async () => {
// Mock response from sitemap endpoint
const mockResponse = {
statusCode: 200,
headers: {
'content-type': 'application/xml; charset=utf-8'
},
body: `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://localhost:3000/documents/abc123</loc>
<lastmod>2024-03-01</lastmod>
</url>
</urlset>`
};
// Verify status code
assert.equal(mockResponse.statusCode, 200, 'Status code must be 200');
// Verify Content-Type header
assert.equal(
mockResponse.headers['content-type'],
'application/xml; charset=utf-8',
'Content-Type must be application/xml; charset=utf-8'
);
// Verify XML structure
assert.match(mockResponse.body, /^<\?xml version="1\.0" encoding="UTF-8"\?>/, 'Must have XML declaration');
assert.match(mockResponse.body, /<urlset xmlns="http:\/\/www\.sitemaps\.org\/schemas\/sitemap\/0\.9">/, 'Must have urlset with correct namespace');
assert.match(mockResponse.body, /<\/urlset>$/, 'Must close urlset tag');
// Verify URL entry structure
assert.match(mockResponse.body, /<url>/, 'Must contain url entries');
assert.match(mockResponse.body, /<loc>.*<\/loc>/, 'Each url must have loc element');
assert.match(mockResponse.body, /<lastmod>.*<\/lastmod>/, 'Each url should have lastmod element');
});
it('should return valid XML with RESTful URL format', async () => {
const mockResponse = {
statusCode: 200,
body: `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://localhost:3000/documents/abc123</loc>
</url>
</urlset>`
};
// Verify RESTful URL pattern: /documents/{documentId}
assert.match(
mockResponse.body,
/<loc>http:\/\/[^<]+\/documents\/[^<]+<\/loc>/,
'URLs must follow RESTful format /documents/{documentId}'
);
});
});
// =============================================================================
// T021: Contract test for /sitemap.xml with empty Drive (0 documents)
// =============================================================================
describe('T021: /sitemap.xml Empty Drive Response Contract', () => {
it('should return valid XML with empty urlset when no documents exist', async () => {
const mockResponse = {
statusCode: 200,
headers: {
'content-type': 'application/xml; charset=utf-8'
},
body: `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
</urlset>`
};
// Verify status code
assert.equal(mockResponse.statusCode, 200, 'Status code must be 200 even for empty Drive');
// Verify empty urlset is valid XML
assert.match(mockResponse.body, /<urlset xmlns="http:\/\/www\.sitemaps\.org\/schemas\/sitemap\/0\.9">/, 'Must have urlset with namespace');
assert.match(mockResponse.body, /<\/urlset>/, 'Must close urlset tag');
// Verify no url entries
assert.doesNotMatch(mockResponse.body, /<url>/, 'Should not contain any url entries');
});
});
// =============================================================================
// T022: Contract test for XML special character escaping
// =============================================================================
describe('T022: XML Special Character Escaping Contract', () => {
it('should properly escape XML special characters in URLs', async () => {
// Document IDs can contain special characters that need escaping in XML
const mockResponse = {
statusCode: 200,
body: `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://localhost:3000/documents/test&amp;doc</loc>
</url>
<url>
<loc>http://localhost:3000/documents/doc&lt;123</loc>
</url>
<url>
<loc>http://localhost:3000/documents/doc&gt;456</loc>
</url>
<url>
<loc>http://localhost:3000/documents/doc&quot;test</loc>
</url>
<url>
<loc>http://localhost:3000/documents/doc&apos;xyz</loc>
</url>
</urlset>`
};
// Verify special characters are escaped
assert.match(mockResponse.body, /&amp;/, 'Ampersand (&) must be escaped as &amp;');
assert.match(mockResponse.body, /&lt;/, 'Less than (<) must be escaped as &lt;');
assert.match(mockResponse.body, /&gt;/, 'Greater than (>) must be escaped as &gt;');
assert.match(mockResponse.body, /&quot;/, 'Double quote (") must be escaped as &quot;');
assert.match(mockResponse.body, /&apos;/, 'Single quote (\') must be escaped as &apos;');
// Verify unescaped special characters are NOT present in content
const locContent = mockResponse.body.match(/<loc>(.*?)<\/loc>/g);
assert.ok(locContent, 'Must have loc elements');
locContent.forEach(loc => {
const content = loc.replace(/<\/?loc>/g, '');
const afterProtocol = content.split('://')[1] || '';
// Only check the path/query part, not the protocol separator
if (afterProtocol.includes('/')) {
const pathPart = afterProtocol.substring(afterProtocol.indexOf('/'));
assert.doesNotMatch(pathPart, /[&<>"'](?!amp;|lt;|gt;|quot;|apos;)/, 'Unescaped special chars must not appear in XML content');
}
});
});
});
// =============================================================================
// T023: Contract test for lastmod date format validation
// =============================================================================
describe('T023: lastmod Date Format Contract', () => {
it('should format lastmod as ISO 8601 date (YYYY-MM-DD)', async () => {
const mockResponse = {
statusCode: 200,
body: `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://localhost:3000/documents/doc1</loc>
<lastmod>2024-03-01</lastmod>
</url>
<url>
<loc>http://localhost:3000/documents/doc2</loc>
<lastmod>2024-12-31</lastmod>
</url>
</urlset>`
};
// Extract lastmod values
const lastmodMatches = mockResponse.body.match(/<lastmod>(.*?)<\/lastmod>/g);
assert.ok(lastmodMatches, 'Must have lastmod elements');
assert.ok(lastmodMatches.length > 0, 'Must have at least one lastmod element');
// Verify each lastmod follows ISO 8601 date format (YYYY-MM-DD)
lastmodMatches.forEach(lastmodTag => {
const dateValue = lastmodTag.match(/<lastmod>(.*?)<\/lastmod>/)[1];
// Check format: YYYY-MM-DD
assert.match(dateValue, /^\d{4}-\d{2}-\d{2}$/, 'lastmod must be in YYYY-MM-DD format');
// Verify it's a valid date
const date = new Date(dateValue);
assert.ok(!isNaN(date.getTime()), 'lastmod must be a valid date');
// Verify date components
const [year, month, day] = dateValue.split('-').map(Number);
assert.ok(year >= 1000 && year <= 9999, 'Year must be 4 digits');
assert.ok(month >= 1 && month <= 12, 'Month must be 01-12');
assert.ok(day >= 1 && day <= 31, 'Day must be 01-31');
});
});
it('should accept full ISO 8601 datetime format if provided', async () => {
// Sitemap protocol also accepts full datetime with timezone
const mockResponse = {
statusCode: 200,
body: `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://localhost:3000/documents/doc1</loc>
<lastmod>2024-03-01T10:30:00+00:00</lastmod>
</url>
</urlset>`
};
const lastmodMatch = mockResponse.body.match(/<lastmod>(.*?)<\/lastmod>/);
assert.ok(lastmodMatch, 'Must have lastmod element');
const dateValue = lastmodMatch[1];
// Accept either YYYY-MM-DD or full ISO 8601 with timezone
const isValidFormat = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2})?$/.test(dateValue);
assert.ok(isValidFormat, 'lastmod must be valid ISO 8601 format');
// Verify it's a valid date
const date = new Date(dateValue);
assert.ok(!isNaN(date.getTime()), 'lastmod must be a valid datetime');
});
});