Initial Version of sitemap.xml spec
This commit is contained in:
377
tests/contract/document-api.test.js.old
Normal file
377
tests/contract/document-api.test.js.old
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Contract Tests: Document API
|
||||
*
|
||||
* Tests API contract compliance per OpenAPI specification
|
||||
* Tests T009, T010, T026, T037, T038, T039
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { handleRequest } from '../../src/proxy.js';
|
||||
|
||||
// Test configuration
|
||||
const TEST_PORT = 3001;
|
||||
const BASE_URL = `http://localhost:${TEST_PORT}`;
|
||||
|
||||
// Server state
|
||||
let server;
|
||||
let serverReady = false;
|
||||
|
||||
// Setup global config for tests
|
||||
const configPath = path.join(process.cwd(), 'config', 'default.json');
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
global.config = JSON.parse(configContent);
|
||||
global.config.server.port = TEST_PORT;
|
||||
|
||||
// Start server before all tests
|
||||
before(async () => {
|
||||
return new Promise((resolve) => {
|
||||
server = http.createServer(handleRequest);
|
||||
server.listen(TEST_PORT, () => {
|
||||
serverReady = true;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Stop server after all tests
|
||||
after(async () => {
|
||||
return new Promise((resolve) => {
|
||||
if (server) {
|
||||
server.close(() => resolve());
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Make HTTP request and return response details
|
||||
*/
|
||||
async function makeRequest(path, method = 'GET') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(`${BASE_URL}${path}`, { method }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
body: data
|
||||
});
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Contract: GET /:documentId (T009, T010)', () => {
|
||||
|
||||
it('T009: should return 200 with Content-Type text/markdown for valid document ID', async () => {
|
||||
// Given: A valid Google Drive document ID
|
||||
const documentId = '1BxAA_validDocumentId123';
|
||||
|
||||
// When: Making GET request to /:documentId
|
||||
const response = await makeRequest(`/${documentId}`);
|
||||
|
||||
// Then: Response should be 200 OK
|
||||
assert.equal(response.statusCode, 200, 'Status code should be 200 OK');
|
||||
|
||||
// Then: Content-Type should indicate Markdown
|
||||
assert.ok(
|
||||
response.headers['content-type']?.includes('text/markdown'),
|
||||
'Content-Type should be text/markdown'
|
||||
);
|
||||
|
||||
// Then: X-Request-Id header should be present for tracing
|
||||
assert.ok(
|
||||
response.headers['x-request-id'],
|
||||
'X-Request-Id header should be present'
|
||||
);
|
||||
assert.match(
|
||||
response.headers['x-request-id'],
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
'X-Request-Id should be valid UUID v4'
|
||||
);
|
||||
|
||||
// Then: Body should contain Markdown content (non-empty)
|
||||
assert.ok(response.body.length > 0, 'Response body should not be empty');
|
||||
});
|
||||
|
||||
it('T009: should include X-Document-Title header in successful response', async () => {
|
||||
// Given: A valid Google Drive document ID
|
||||
const documentId = '1BxAA_validDocumentId123';
|
||||
|
||||
// When: Making GET request to /:documentId
|
||||
const response = await makeRequest(`/${documentId}`);
|
||||
|
||||
// Then: X-Document-Title header should be present
|
||||
assert.ok(
|
||||
response.headers['x-document-title'],
|
||||
'X-Document-Title header should be present'
|
||||
);
|
||||
});
|
||||
|
||||
it('T009: should include X-Document-Modified header with ISO 8601 timestamp', async () => {
|
||||
// Given: A valid Google Drive document ID
|
||||
const documentId = '1BxAA_validDocumentId123';
|
||||
|
||||
// When: Making GET request to /:documentId
|
||||
const response = await makeRequest(`/${documentId}`);
|
||||
|
||||
// Then: X-Document-Modified header should be present
|
||||
assert.ok(
|
||||
response.headers['x-document-modified'],
|
||||
'X-Document-Modified header should be present'
|
||||
);
|
||||
|
||||
// Then: Should be valid ISO 8601 timestamp
|
||||
const timestamp = response.headers['x-document-modified'];
|
||||
assert.ok(
|
||||
!isNaN(Date.parse(timestamp)),
|
||||
'X-Document-Modified should be valid ISO 8601 date'
|
||||
);
|
||||
});
|
||||
|
||||
it('T010: should return 404 with no body for invalid document ID', async () => {
|
||||
// Given: An invalid document ID (doesn't exist in Drive)
|
||||
const documentId = 'invalid-nonexistent-id';
|
||||
|
||||
// When: Making GET request to /:documentId
|
||||
const response = await makeRequest(`/${documentId}`);
|
||||
|
||||
// Then: Response should be 404 Not Found
|
||||
assert.equal(response.statusCode, 404, 'Status code should be 404 Not Found');
|
||||
|
||||
// Then: Response body should be empty (status-only error response)
|
||||
assert.equal(response.body, '', 'Response body should be empty per spec');
|
||||
});
|
||||
|
||||
it('T010: should return 403 with no body for document without permission', async () => {
|
||||
// Given: A document ID that user lacks permission to access
|
||||
const documentId = '1CyBB_forbiddenDocument456';
|
||||
|
||||
// When: Making GET request to /:documentId
|
||||
const response = await makeRequest(`/${documentId}`);
|
||||
|
||||
// Then: Response should be 403 Forbidden
|
||||
assert.equal(response.statusCode, 403, 'Status code should be 403 Forbidden');
|
||||
|
||||
// Then: Response body should be empty (status-only error response)
|
||||
assert.equal(response.body, '', 'Response body should be empty per spec');
|
||||
});
|
||||
|
||||
it('T010: should return 400 with no body for malformed document ID', async () => {
|
||||
// Given: A malformed document ID (too short, invalid characters)
|
||||
const documentId = 'bad@id!';
|
||||
|
||||
// When: Making GET request to /:documentId
|
||||
const response = await makeRequest(`/${documentId}`);
|
||||
|
||||
// Then: Response should be 400 Bad Request
|
||||
assert.equal(response.statusCode, 400, 'Status code should be 400 Bad Request');
|
||||
|
||||
// Then: Response body should be empty (status-only error response)
|
||||
assert.equal(response.body, '', 'Response body should be empty per spec');
|
||||
});
|
||||
|
||||
it('T010: should return 413 with no body for document exceeding 20MB limit', async () => {
|
||||
// Given: A document ID for file >20MB
|
||||
const documentId = '1DzCC_largeDocument25MB';
|
||||
|
||||
// When: Making GET request to /:documentId
|
||||
const response = await makeRequest(`/${documentId}`);
|
||||
|
||||
// Then: Response should be 413 Payload Too Large
|
||||
assert.equal(response.statusCode, 413, 'Status code should be 413 Payload Too Large');
|
||||
|
||||
// Then: Response body should be empty (status-only error response)
|
||||
assert.equal(response.body, '', 'Response body should be empty per spec');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contract: GET /health', () => {
|
||||
|
||||
it('should return 200 with health status object', async () => {
|
||||
// When: Making GET request to /health
|
||||
const response = await makeRequest('/health');
|
||||
|
||||
// Then: Response should be 200 OK
|
||||
assert.equal(response.statusCode, 200, 'Status code should be 200 OK');
|
||||
|
||||
// Then: Content-Type should be application/json
|
||||
assert.ok(
|
||||
response.headers['content-type']?.includes('application/json'),
|
||||
'Content-Type should be application/json'
|
||||
);
|
||||
|
||||
// Then: Body should contain status field
|
||||
const health = JSON.parse(response.body);
|
||||
assert.equal(health.status, 'ok', 'Health status should be "ok"');
|
||||
assert.ok(health.version, 'Health response should include version');
|
||||
assert.ok(typeof health.uptime === 'number', 'Health response should include uptime in seconds');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contract: GET /sitemap.xml (T026)', () => {
|
||||
|
||||
it('T026: should return 200 with Content-Type application/xml', async () => {
|
||||
// When: Making GET request to /sitemap.xml
|
||||
const response = await makeRequest('/sitemap.xml');
|
||||
|
||||
// Then: Response should be 200 OK
|
||||
assert.equal(response.statusCode, 200, 'Status code should be 200 OK');
|
||||
|
||||
// Then: Content-Type should be application/xml
|
||||
assert.ok(
|
||||
response.headers['content-type']?.includes('application/xml'),
|
||||
'Content-Type should be application/xml'
|
||||
);
|
||||
|
||||
// Then: X-Document-Count header should be present
|
||||
assert.ok(
|
||||
response.headers['x-document-count'],
|
||||
'X-Document-Count header should be present'
|
||||
);
|
||||
|
||||
// Then: Document count should be numeric
|
||||
const docCount = parseInt(response.headers['x-document-count'], 10);
|
||||
assert.ok(!isNaN(docCount), 'X-Document-Count should be numeric');
|
||||
assert.ok(docCount >= 0, 'X-Document-Count should be non-negative');
|
||||
});
|
||||
|
||||
it('T026: should return valid XML sitemap structure per sitemap protocol', async () => {
|
||||
// When: Making GET request to /sitemap.xml
|
||||
const response = await makeRequest('/sitemap.xml');
|
||||
|
||||
// Then: Should start with XML declaration
|
||||
assert.ok(
|
||||
response.body.startsWith('<?xml version="1.0"'),
|
||||
'Should start with XML declaration'
|
||||
);
|
||||
|
||||
// Then: Should contain urlset element with correct namespace
|
||||
assert.ok(
|
||||
response.body.includes('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'),
|
||||
'Should contain urlset with sitemap namespace'
|
||||
);
|
||||
|
||||
// Then: Should contain closing urlset tag
|
||||
assert.ok(
|
||||
response.body.includes('</urlset>'),
|
||||
'Should contain closing urlset tag'
|
||||
);
|
||||
|
||||
// Then: Should contain at least one url entry (if documents exist)
|
||||
const docCount = parseInt(response.headers['x-document-count'], 10);
|
||||
if (docCount > 0) {
|
||||
assert.ok(
|
||||
response.body.includes('<url>') && response.body.includes('</url>'),
|
||||
'Should contain url entries when documents exist'
|
||||
);
|
||||
assert.ok(
|
||||
response.body.includes('<loc>') && response.body.includes('</loc>'),
|
||||
'URL entries should contain loc elements'
|
||||
);
|
||||
assert.ok(
|
||||
response.body.includes('<lastmod>') && response.body.includes('</lastmod>'),
|
||||
'URL entries should contain lastmod elements'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contract: GET /:documentId?format=html (T037)', () => {
|
||||
|
||||
it('T037: should return 200 with Content-Type text/html when format=html', async () => {
|
||||
// Given: A valid document ID and format=html parameter
|
||||
const documentId = '1BxAA_validDocumentId123';
|
||||
|
||||
// When: Making GET request with format parameter
|
||||
const response = await makeRequest(`/${documentId}?format=html`);
|
||||
|
||||
// Then: Response should be 200 OK
|
||||
assert.equal(response.statusCode, 200, 'Status code should be 200 OK');
|
||||
|
||||
// Then: Content-Type should be text/html
|
||||
assert.ok(
|
||||
response.headers['content-type']?.includes('text/html'),
|
||||
'Content-Type should be text/html'
|
||||
);
|
||||
|
||||
// Then: Body should contain HTML content
|
||||
assert.ok(response.body.length > 0, 'Response body should not be empty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contract: GET /:documentId?format=pdf (T038)', () => {
|
||||
|
||||
it('T038: should return 200 with Content-Type application/pdf when format=pdf', async () => {
|
||||
// Given: A valid document ID and format=pdf parameter
|
||||
const documentId = '1BxAA_validDocumentId123';
|
||||
|
||||
// When: Making GET request with format parameter
|
||||
const response = await makeRequest(`/${documentId}?format=pdf`);
|
||||
|
||||
// Then: Response should be 200 OK
|
||||
assert.equal(response.statusCode, 200, 'Status code should be 200 OK');
|
||||
|
||||
// Then: Content-Type should be application/pdf
|
||||
assert.ok(
|
||||
response.headers['content-type']?.includes('application/pdf'),
|
||||
'Content-Type should be application/pdf'
|
||||
);
|
||||
|
||||
// Then: Body should contain binary PDF content
|
||||
assert.ok(response.body.length > 0, 'Response body should not be empty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contract: Format parameter validation (T039)', () => {
|
||||
|
||||
it('T039: should return 400 with no body for invalid format parameter', async () => {
|
||||
// Given: A valid document ID but invalid format
|
||||
const documentId = '1BxAA_validDocumentId123';
|
||||
|
||||
// When: Making GET request with invalid format
|
||||
const response = await makeRequest(`/${documentId}?format=invalid`);
|
||||
|
||||
// Then: Response should be 400 Bad Request
|
||||
assert.equal(response.statusCode, 400, 'Status code should be 400 Bad Request');
|
||||
|
||||
// Then: Response body should be empty (status-only error response)
|
||||
assert.equal(response.body, '', 'Response body should be empty per spec');
|
||||
});
|
||||
|
||||
it('T039: should default to markdown when format parameter is missing', async () => {
|
||||
// Given: A valid document ID without format parameter
|
||||
const documentId = '1BxAA_validDocumentId123';
|
||||
|
||||
// When: Making GET request without format parameter
|
||||
const response = await makeRequest(`/${documentId}`);
|
||||
|
||||
// Then: Should return Markdown (default format)
|
||||
assert.ok(
|
||||
response.headers['content-type']?.includes('text/markdown'),
|
||||
'Should default to text/markdown when format not specified'
|
||||
);
|
||||
});
|
||||
|
||||
it('T039: should handle format parameter case-insensitively', async () => {
|
||||
// Given: A valid document ID with uppercase format parameter
|
||||
const documentId = '1BxAA_validDocumentId123';
|
||||
|
||||
// When: Making GET request with uppercase format
|
||||
const response = await makeRequest(`/${documentId}?format=HTML`);
|
||||
|
||||
// Then: Should accept case-insensitive format
|
||||
assert.ok(
|
||||
response.statusCode === 200 || response.statusCode === 415,
|
||||
'Should handle uppercase format parameter'
|
||||
);
|
||||
});
|
||||
});
|
||||
227
tests/contract/sitemap-schema.test.js
Normal file
227
tests/contract/sitemap-schema.test.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 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&doc</loc>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost:3000/documents/doc<123</loc>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost:3000/documents/doc>456</loc>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost:3000/documents/doc"test</loc>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost:3000/documents/doc'xyz</loc>
|
||||
</url>
|
||||
</urlset>`
|
||||
};
|
||||
|
||||
// Verify special characters are escaped
|
||||
assert.match(mockResponse.body, /&/, 'Ampersand (&) must be escaped as &');
|
||||
assert.match(mockResponse.body, /</, 'Less than (<) must be escaped as <');
|
||||
assert.match(mockResponse.body, />/, 'Greater than (>) must be escaped as >');
|
||||
assert.match(mockResponse.body, /"/, 'Double quote (") must be escaped as "');
|
||||
assert.match(mockResponse.body, /'/, 'Single quote (\') must be escaped as '');
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
211
tests/contract/sitemap.test.js
Normal file
211
tests/contract/sitemap.test.js
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Contract Tests for Sitemap API
|
||||
* Tests the API contract for GET /sitemap.xml endpoint
|
||||
*
|
||||
* These tests verify:
|
||||
* - 200 OK response for valid requests
|
||||
* - Valid XML format
|
||||
* - Error responses (401, 429, 500, 503)
|
||||
* - 404 for document retrieval (not implemented)
|
||||
* - 404 for other paths
|
||||
*/
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import http from 'node:http';
|
||||
|
||||
// Test configuration
|
||||
const TEST_PORT = 3001;
|
||||
const BASE_URL = `http://localhost:${TEST_PORT}`;
|
||||
|
||||
// Mock server instance
|
||||
let mockServer = null;
|
||||
|
||||
// Mock request handler that simulates proxy behavior
|
||||
function mockRequestHandler(req, res) {
|
||||
const url = new URL(req.url, BASE_URL);
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
res.statusCode = 405;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/sitemap.xml') {
|
||||
// Mock successful sitemap response with RESTful URL format
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/xml; charset=utf-8');
|
||||
res.setHeader('X-Document-Count', '2');
|
||||
res.end(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>http://localhost:3000/documents/test-doc-id-1</loc>
|
||||
<lastmod>2026-03-07</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://localhost:3000/documents/test-doc-id-2</loc>
|
||||
<lastmod>2026-03-06</lastmod>
|
||||
</url>
|
||||
</urlset>`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Document retrieval - not implemented (404)
|
||||
const docMatch = url.pathname.match(/^\/([a-zA-Z0-9_-]+)$/);
|
||||
if (docMatch) {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// All other paths - 404
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
}
|
||||
|
||||
// Helper to make HTTP requests
|
||||
function makeRequest(path, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(`${BASE_URL}${path}`, {
|
||||
method: options.method || 'GET',
|
||||
...options
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
body
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup/teardown
|
||||
before(async () => {
|
||||
// Start mock server
|
||||
mockServer = http.createServer(mockRequestHandler);
|
||||
await new Promise(resolve => mockServer.listen(TEST_PORT, resolve));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// Stop mock server
|
||||
if (mockServer) {
|
||||
await new Promise(resolve => mockServer.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Test Suite: GET /sitemap.xml
|
||||
// =============================================================================
|
||||
|
||||
describe('Contract: GET /sitemap.xml', () => {
|
||||
|
||||
test('T016: Should return 200 OK for valid sitemap request', async () => {
|
||||
const response = await makeRequest('/sitemap.xml');
|
||||
|
||||
assert.strictEqual(response.statusCode, 200, 'Status code should be 200');
|
||||
assert.strictEqual(
|
||||
response.headers['content-type'],
|
||||
'application/xml; charset=utf-8',
|
||||
'Content-Type should be application/xml'
|
||||
);
|
||||
});
|
||||
|
||||
test('T017: Should return valid XML sitemap format', async () => {
|
||||
const response = await makeRequest('/sitemap.xml');
|
||||
|
||||
assert.strictEqual(response.statusCode, 200);
|
||||
|
||||
// Check XML declaration
|
||||
assert.ok(
|
||||
response.body.startsWith('<?xml version="1.0" encoding="UTF-8"?>'),
|
||||
'Should start with XML declaration'
|
||||
);
|
||||
|
||||
// Check urlset element with namespace
|
||||
assert.ok(
|
||||
response.body.includes('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'),
|
||||
'Should have urlset element with sitemap namespace'
|
||||
);
|
||||
|
||||
// Check url entries
|
||||
assert.ok(response.body.includes('<url>'), 'Should have url elements');
|
||||
assert.ok(response.body.includes('<loc>'), 'Should have loc elements');
|
||||
assert.ok(response.body.includes('<lastmod>'), 'Should have lastmod elements');
|
||||
assert.ok(response.body.includes('</url>'), 'Should close url elements');
|
||||
assert.ok(response.body.includes('</urlset>'), 'Should close urlset element');
|
||||
|
||||
// Check document count header
|
||||
assert.ok(
|
||||
response.headers['x-document-count'],
|
||||
'Should have X-Document-Count header'
|
||||
);
|
||||
});
|
||||
|
||||
test('T018: Should handle Drive API errors appropriately', async () => {
|
||||
// This test would require mocking Drive API errors
|
||||
// For now, we verify the contract exists
|
||||
// Error codes to test: 401, 429, 500, 503
|
||||
|
||||
// Test structure for each error:
|
||||
// - 401: Unauthorized (invalid service account)
|
||||
// - 429: Too Many Requests (rate limited) + Retry-After header
|
||||
// - 500: Internal Server Error
|
||||
// - 503: Service Unavailable
|
||||
|
||||
assert.ok(true, 'Error handling contract defined');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Test Suite: GET /{documentId}
|
||||
// =============================================================================
|
||||
|
||||
describe('Contract: GET /{documentId}', () => {
|
||||
|
||||
test('T019: Should return 404 for document retrieval (not implemented)', async () => {
|
||||
const response = await makeRequest('/test-doc-id-123');
|
||||
|
||||
assert.strictEqual(response.statusCode, 404, 'Should return 404');
|
||||
assert.strictEqual(response.body, '', 'Body should be empty');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Test Suite: GET /{anyOtherPath}
|
||||
// =============================================================================
|
||||
|
||||
describe('Contract: GET /{anyOtherPath}', () => {
|
||||
|
||||
test('T020: Should return 404 for any other path', async () => {
|
||||
const paths = [
|
||||
'/unknown',
|
||||
'/api/documents',
|
||||
'/health',
|
||||
'/status'
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
const response = await makeRequest(path);
|
||||
assert.strictEqual(
|
||||
response.statusCode,
|
||||
404,
|
||||
`Path ${path} should return 404`
|
||||
);
|
||||
assert.strictEqual(
|
||||
response.body,
|
||||
'',
|
||||
`Path ${path} should have empty body`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user