Initial commit
This commit is contained in:
@@ -0,0 +1,924 @@
|
||||
/**
|
||||
* Property-Based Tests for Content Source URL Validation
|
||||
*
|
||||
* Feature: bot-system, Property 11: Content Source URL Validation
|
||||
*
|
||||
* Tests the content source URL and type validation using fast-check
|
||||
* for property-based testing.
|
||||
*
|
||||
* **Validates: Requirements 4.1**
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fc from 'fast-check';
|
||||
import {
|
||||
validateSourceUrl,
|
||||
validateSourceType,
|
||||
validateContentSourceConfig,
|
||||
isSupportedSourceType,
|
||||
isValidUrl,
|
||||
ContentSourceType,
|
||||
SUPPORTED_SOURCE_TYPES,
|
||||
ContentSourceValidationResult,
|
||||
} from './contentSource';
|
||||
|
||||
// ============================================
|
||||
// GENERATORS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Generator for invalid URLs - strings that should NOT pass URL validation.
|
||||
*
|
||||
* Invalid URLs include:
|
||||
* - Empty strings
|
||||
* - Strings without protocol
|
||||
* - Strings with invalid protocols (ftp, file, etc.)
|
||||
* - Malformed URLs
|
||||
* - Strings with spaces
|
||||
* - Random garbage strings
|
||||
*/
|
||||
|
||||
// Generator for empty or whitespace-only strings
|
||||
const emptyOrWhitespaceArb = fc.oneof(
|
||||
fc.constant(''),
|
||||
fc.constant(' '),
|
||||
fc.constant('\t\n'),
|
||||
fc.constant(' \t '),
|
||||
fc.constant('\n\r\t'),
|
||||
fc.constant(' ')
|
||||
);
|
||||
|
||||
// Generator for URLs without protocol
|
||||
const noProtocolUrlArb = fc.oneof(
|
||||
fc.constant('example.com'),
|
||||
fc.constant('www.example.com/path'),
|
||||
fc.constant('reddit.com/r/test'),
|
||||
fc.stringMatching(/^[a-z0-9]+\.[a-z]{2,4}(\/[a-z0-9]+)?$/)
|
||||
);
|
||||
|
||||
// Generator for URLs with invalid protocols
|
||||
const invalidProtocolUrlArb = fc.oneof(
|
||||
fc.constant('ftp://example.com'),
|
||||
fc.constant('file:///path/to/file'),
|
||||
fc.constant('mailto:test@example.com'),
|
||||
fc.constant('javascript:alert(1)'),
|
||||
fc.constant('data:text/html,<h1>test</h1>'),
|
||||
fc.stringMatching(/^[a-z]{2,10}:\/\/[a-z0-9]+\.[a-z]{2,4}$/)
|
||||
.filter(s => !s.startsWith('http://') && !s.startsWith('https://'))
|
||||
);
|
||||
|
||||
// Generator for malformed URLs (invalid characters, structure)
|
||||
// Note: Only include URLs that will actually fail URL parsing
|
||||
const malformedUrlArb = fc.oneof(
|
||||
fc.constant('http://'),
|
||||
fc.constant('https://'),
|
||||
fc.constant('http://example .com'), // Space in hostname
|
||||
fc.constant('http://exam ple.com/path'), // Space in hostname
|
||||
fc.constant('http://[invalid'), // Invalid IPv6
|
||||
fc.constant('http://example.com:abc'), // Invalid port
|
||||
fc.constant('http://user:pass@'), // Missing host after auth
|
||||
fc.constant('://example.com'), // Missing protocol
|
||||
);
|
||||
|
||||
// Generator for URLs that are too long (> 2048 characters)
|
||||
const tooLongUrlArb = fc.stringMatching(/^[a-z0-9]{2100,2500}$/)
|
||||
.map(s => `https://example.com/${s}`);
|
||||
|
||||
// Generator for random garbage strings (most will be invalid URLs)
|
||||
const randomGarbageArb = fc.string({ minLength: 1, maxLength: 100 })
|
||||
.filter(s => {
|
||||
try {
|
||||
new URL(s);
|
||||
return false; // Filter out accidentally valid URLs
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Combined generator for all invalid URLs
|
||||
const invalidUrlArb = fc.oneof(
|
||||
emptyOrWhitespaceArb,
|
||||
noProtocolUrlArb,
|
||||
invalidProtocolUrlArb,
|
||||
malformedUrlArb,
|
||||
randomGarbageArb
|
||||
);
|
||||
|
||||
/**
|
||||
* Generator for unsupported source types.
|
||||
*
|
||||
* Unsupported types include:
|
||||
* - Empty strings
|
||||
* - Random strings that aren't 'rss', 'reddit', or 'news_api'
|
||||
* - Typos of valid types
|
||||
* - Non-string values
|
||||
*/
|
||||
|
||||
// Generator for unsupported type strings
|
||||
const unsupportedTypeStringArb = fc.string({ minLength: 1, maxLength: 50 })
|
||||
.filter(s => !SUPPORTED_SOURCE_TYPES.includes(s as ContentSourceType));
|
||||
|
||||
// Generator for typos of valid types
|
||||
const typoTypeArb = fc.oneof(
|
||||
fc.constant('RSS'),
|
||||
fc.constant('Rss'),
|
||||
fc.constant('rss '),
|
||||
fc.constant(' rss'),
|
||||
fc.constant('reddit '),
|
||||
fc.constant('Reddit'),
|
||||
fc.constant('REDDIT'),
|
||||
fc.constant('news_API'),
|
||||
fc.constant('newsapi'),
|
||||
fc.constant('news-api'),
|
||||
fc.constant('atom'),
|
||||
fc.constant('twitter'),
|
||||
fc.constant('facebook'),
|
||||
fc.constant('api'),
|
||||
fc.constant('feed'),
|
||||
);
|
||||
|
||||
// Combined generator for unsupported types
|
||||
const unsupportedTypeArb = fc.oneof(
|
||||
unsupportedTypeStringArb,
|
||||
typoTypeArb,
|
||||
fc.constant(''),
|
||||
);
|
||||
|
||||
/**
|
||||
* Generator for valid URLs (for testing type-specific validation).
|
||||
*/
|
||||
const validHttpUrlArb = fc.oneof(
|
||||
fc.constant('https://example.com'),
|
||||
fc.constant('https://example.com/feed.xml'),
|
||||
fc.constant('http://test.org/rss'),
|
||||
fc.stringMatching(/^[a-z0-9]{3,20}$/).map(s => `https://${s}.com/feed`)
|
||||
);
|
||||
|
||||
/**
|
||||
* Generator for invalid Reddit URLs (valid HTTP URLs but not Reddit format).
|
||||
*/
|
||||
const invalidRedditUrlArb = fc.oneof(
|
||||
fc.constant('https://example.com'),
|
||||
fc.constant('https://twitter.com/user'),
|
||||
fc.constant('https://reddit.com'), // Missing /r/subreddit
|
||||
fc.constant('https://reddit.com/user/test'),
|
||||
fc.constant('https://reddit.com/r/'), // Missing subreddit name
|
||||
fc.constant('https://notreddit.com/r/test'),
|
||||
validHttpUrlArb.filter(url => !url.includes('reddit.com/r/'))
|
||||
);
|
||||
|
||||
/**
|
||||
* Generator for invalid News API URLs (valid HTTP URLs but not API format).
|
||||
*/
|
||||
const invalidNewsApiUrlArb = fc.oneof(
|
||||
fc.constant('https://example.com'),
|
||||
fc.constant('https://news.google.com'),
|
||||
fc.constant('https://cnn.com/news'),
|
||||
fc.constant('http://bbc.com/feed'),
|
||||
validHttpUrlArb.filter(url =>
|
||||
!url.includes('newsapi.org') &&
|
||||
!url.includes('gnews.io') &&
|
||||
!url.includes('newsdata.io') &&
|
||||
!url.includes('api.')
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Generator for valid RSS URLs.
|
||||
*/
|
||||
const validRssUrlArb = fc.oneof(
|
||||
fc.constant('https://example.com/feed.xml'),
|
||||
fc.constant('https://blog.example.org/rss'),
|
||||
fc.constant('http://news.site.com/feed'),
|
||||
fc.stringMatching(/^[a-z0-9]{3,15}$/).map(s => `https://${s}.com/feed.xml`)
|
||||
);
|
||||
|
||||
/**
|
||||
* Generator for valid Reddit URLs.
|
||||
*/
|
||||
const validRedditUrlArb = fc.oneof(
|
||||
fc.constant('https://reddit.com/r/programming'),
|
||||
fc.constant('https://www.reddit.com/r/technology'),
|
||||
fc.constant('https://old.reddit.com/r/news'),
|
||||
fc.stringMatching(/^[a-zA-Z0-9_]{3,21}$/).map(s => `https://reddit.com/r/${s}`)
|
||||
);
|
||||
|
||||
/**
|
||||
* Generator for valid News API URLs.
|
||||
*/
|
||||
const validNewsApiUrlArb = fc.oneof(
|
||||
fc.constant('https://newsapi.org/v2/everything'),
|
||||
fc.constant('https://gnews.io/api/v4/search'),
|
||||
fc.constant('https://api.newsdata.io/v1/news'),
|
||||
fc.constant('https://api.example.com/news'),
|
||||
);
|
||||
|
||||
/**
|
||||
* Generator for supported source types.
|
||||
*/
|
||||
const supportedTypeArb = fc.constantFrom<ContentSourceType>('rss', 'reddit', 'news_api');
|
||||
|
||||
// ============================================
|
||||
// PROPERTY TESTS
|
||||
// ============================================
|
||||
|
||||
describe('Feature: bot-system, Property 11: Content Source URL Validation', () => {
|
||||
/**
|
||||
* Property 11: Content Source URL Validation
|
||||
*
|
||||
* *For any* invalid URL or unsupported source type, adding a content source
|
||||
* SHALL fail with a validation error.
|
||||
*
|
||||
* **Validates: Requirements 4.1**
|
||||
*/
|
||||
|
||||
describe('Invalid URLs are rejected', () => {
|
||||
it('rejects empty or whitespace-only URLs for all source types', () => {
|
||||
fc.assert(
|
||||
fc.property(emptyOrWhitespaceArb, supportedTypeArb, (url, type) => {
|
||||
const errors = validateSourceUrl(url, type);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e =>
|
||||
e.includes('required') ||
|
||||
e.includes('empty') ||
|
||||
e.includes('valid')
|
||||
)).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects URLs without HTTP/HTTPS protocol for all source types', () => {
|
||||
fc.assert(
|
||||
fc.property(noProtocolUrlArb, supportedTypeArb, (url, type) => {
|
||||
const errors = validateSourceUrl(url, type);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects URLs with invalid protocols for all source types', () => {
|
||||
fc.assert(
|
||||
fc.property(invalidProtocolUrlArb, supportedTypeArb, (url, type) => {
|
||||
const errors = validateSourceUrl(url, type);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed URLs for all source types', () => {
|
||||
fc.assert(
|
||||
fc.property(malformedUrlArb, supportedTypeArb, (url, type) => {
|
||||
const errors = validateSourceUrl(url, type);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects URLs that are too long (> 2048 characters)', () => {
|
||||
fc.assert(
|
||||
fc.property(tooLongUrlArb, supportedTypeArb, (url, type) => {
|
||||
const errors = validateSourceUrl(url, type);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.includes('too long'))).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects random garbage strings as URLs', () => {
|
||||
fc.assert(
|
||||
fc.property(randomGarbageArb, supportedTypeArb, (url, type) => {
|
||||
const errors = validateSourceUrl(url, type);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unsupported source types are rejected', () => {
|
||||
it('rejects unsupported source type strings', () => {
|
||||
fc.assert(
|
||||
fc.property(unsupportedTypeStringArb, (type) => {
|
||||
const errors = validateSourceType(type);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.includes('Unsupported source type'))).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects typos of valid source types', () => {
|
||||
fc.assert(
|
||||
fc.property(typoTypeArb, (type) => {
|
||||
const errors = validateSourceType(type);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty string as source type', () => {
|
||||
const errors = validateSourceType('');
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.includes('required'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects null and undefined as source type', () => {
|
||||
const nullErrors = validateSourceType(null);
|
||||
const undefinedErrors = validateSourceType(undefined);
|
||||
|
||||
expect(nullErrors.length).toBeGreaterThan(0);
|
||||
expect(undefinedErrors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects non-string values as source type', () => {
|
||||
const invalidTypes = [123, {}, [], true, false];
|
||||
|
||||
for (const invalidType of invalidTypes) {
|
||||
const errors = validateSourceType(invalidType);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type-specific URL validation', () => {
|
||||
it('rejects non-Reddit URLs for Reddit source type', () => {
|
||||
fc.assert(
|
||||
fc.property(invalidRedditUrlArb, (url) => {
|
||||
const errors = validateSourceUrl(url, 'reddit');
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.includes('Reddit') || e.includes('subreddit'))).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-API URLs for news_api source type', () => {
|
||||
fc.assert(
|
||||
fc.property(invalidNewsApiUrlArb, (url) => {
|
||||
const errors = validateSourceUrl(url, 'news_api');
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.includes('API') || e.includes('endpoint'))).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete configuration validation', () => {
|
||||
it('rejects configurations with invalid URL and valid type', () => {
|
||||
fc.assert(
|
||||
fc.property(invalidUrlArb, supportedTypeArb, (url, type) => {
|
||||
const config = { url, type };
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects configurations with valid URL and invalid type', () => {
|
||||
fc.assert(
|
||||
fc.property(validHttpUrlArb, unsupportedTypeArb, (url, type) => {
|
||||
const config = { url, type };
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects configurations with both invalid URL and invalid type', () => {
|
||||
fc.assert(
|
||||
fc.property(invalidUrlArb, unsupportedTypeArb, (url, type) => {
|
||||
const config = { url, type };
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-object configurations', () => {
|
||||
const invalidConfigs = [null, undefined, 'string', 123, [], true];
|
||||
|
||||
for (const config of invalidConfigs) {
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects configurations missing required fields', () => {
|
||||
const incompleteConfigs = [
|
||||
{},
|
||||
{ url: 'https://example.com' }, // Missing type
|
||||
{ type: 'rss' }, // Missing url
|
||||
];
|
||||
|
||||
for (const config of incompleteConfigs) {
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Valid configurations are accepted', () => {
|
||||
it('accepts valid RSS configurations', () => {
|
||||
fc.assert(
|
||||
fc.property(validRssUrlArb, (url) => {
|
||||
const config = { url, type: 'rss' as const };
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts valid Reddit configurations with subreddit', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
validRedditUrlArb,
|
||||
fc.stringMatching(/^[a-zA-Z0-9_]{3,21}$/),
|
||||
(url, subreddit) => {
|
||||
const config = { url, type: 'reddit' as const, subreddit };
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts valid News API configurations with API key', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
validNewsApiUrlArb,
|
||||
fc.stringMatching(/^[a-zA-Z0-9]{10,100}$/),
|
||||
(url, apiKey) => {
|
||||
const config = { url, type: 'news_api' as const, apiKey };
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Helper function validation', () => {
|
||||
it('isSupportedSourceType returns false for unsupported types', () => {
|
||||
fc.assert(
|
||||
fc.property(unsupportedTypeStringArb, (type) => {
|
||||
expect(isSupportedSourceType(type)).toBe(false);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('isSupportedSourceType returns true for supported types', () => {
|
||||
for (const type of SUPPORTED_SOURCE_TYPES) {
|
||||
expect(isSupportedSourceType(type)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('isValidUrl returns false for invalid URLs', () => {
|
||||
fc.assert(
|
||||
fc.property(invalidUrlArb, (url) => {
|
||||
expect(isValidUrl(url)).toBe(false);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('isValidUrl returns true for valid HTTP/HTTPS URLs', () => {
|
||||
fc.assert(
|
||||
fc.property(validHttpUrlArb, (url) => {
|
||||
expect(isValidUrl(url)).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('isValidUrl returns false for non-string inputs', () => {
|
||||
const invalidInputs = [null, undefined, 123, {}, [], true];
|
||||
|
||||
for (const input of invalidInputs) {
|
||||
expect(isValidUrl(input as unknown as string)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// PROPERTY 14: MULTIPLE SOURCE TYPES PER BOT
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Property-Based Tests for Multiple Source Types Per Bot
|
||||
*
|
||||
* Feature: bot-system, Property 14: Multiple Source Types Per Bot
|
||||
*
|
||||
* Tests that bots can have content sources of different types (RSS, Reddit, news API)
|
||||
* and all succeed and are retrievable.
|
||||
*
|
||||
* **Validates: Requirements 4.6**
|
||||
*/
|
||||
|
||||
describe('Feature: bot-system, Property 14: Multiple Source Types Per Bot', () => {
|
||||
/**
|
||||
* Property 14: Multiple Source Types Per Bot
|
||||
*
|
||||
* *For any* bot, adding content sources of different types (RSS, Reddit, news API)
|
||||
* SHALL all succeed and be retrievable.
|
||||
*
|
||||
* **Validates: Requirements 4.6**
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// GENERATORS FOR PROPERTY 14
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Generator for valid RSS source configurations.
|
||||
*/
|
||||
const validRssConfigArb = fc.record({
|
||||
type: fc.constant('rss' as const),
|
||||
url: fc.oneof(
|
||||
fc.constant('https://example.com/feed.xml'),
|
||||
fc.constant('https://blog.example.org/rss'),
|
||||
fc.constant('http://news.site.com/feed'),
|
||||
fc.stringMatching(/^[a-z0-9]{3,15}$/).map(s => `https://${s}.com/feed.xml`)
|
||||
),
|
||||
fetchInterval: fc.option(fc.integer({ min: 5, max: 1440 }), { nil: undefined }),
|
||||
keywords: fc.option(
|
||||
fc.array(fc.stringMatching(/^[a-zA-Z0-9]{1,50}$/), { minLength: 0, maxLength: 5 }),
|
||||
{ nil: undefined }
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Generator for valid Reddit source configurations.
|
||||
*/
|
||||
const validRedditConfigArb = fc.record({
|
||||
type: fc.constant('reddit' as const),
|
||||
url: fc.oneof(
|
||||
fc.constant('https://reddit.com/r/programming'),
|
||||
fc.constant('https://www.reddit.com/r/technology'),
|
||||
fc.constant('https://old.reddit.com/r/news'),
|
||||
fc.stringMatching(/^[a-zA-Z0-9_]{3,21}$/).map(s => `https://reddit.com/r/${s}`)
|
||||
),
|
||||
subreddit: fc.stringMatching(/^[a-zA-Z0-9_]{3,21}$/),
|
||||
fetchInterval: fc.option(fc.integer({ min: 5, max: 1440 }), { nil: undefined }),
|
||||
keywords: fc.option(
|
||||
fc.array(fc.stringMatching(/^[a-zA-Z0-9]{1,50}$/), { minLength: 0, maxLength: 5 }),
|
||||
{ nil: undefined }
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Generator for valid News API source configurations.
|
||||
*/
|
||||
const validNewsApiConfigArb = fc.record({
|
||||
type: fc.constant('news_api' as const),
|
||||
url: fc.oneof(
|
||||
fc.constant('https://newsapi.org/v2/everything'),
|
||||
fc.constant('https://gnews.io/api/v4/search'),
|
||||
fc.constant('https://api.newsdata.io/v1/news'),
|
||||
fc.constant('https://api.example.com/news')
|
||||
),
|
||||
apiKey: fc.stringMatching(/^[a-zA-Z0-9]{10,100}$/),
|
||||
fetchInterval: fc.option(fc.integer({ min: 5, max: 1440 }), { nil: undefined }),
|
||||
keywords: fc.option(
|
||||
fc.array(fc.stringMatching(/^[a-zA-Z0-9]{1,50}$/), { minLength: 0, maxLength: 5 }),
|
||||
{ nil: undefined }
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Generator for a combination of all three source types.
|
||||
*/
|
||||
const allSourceTypesConfigArb = fc.tuple(
|
||||
validRssConfigArb,
|
||||
validRedditConfigArb,
|
||||
validNewsApiConfigArb
|
||||
);
|
||||
|
||||
/**
|
||||
* Generator for a non-empty subset of source types.
|
||||
*/
|
||||
const sourceTypeSubsetArb = fc.oneof(
|
||||
// Single types
|
||||
fc.tuple(validRssConfigArb).map(configs => configs),
|
||||
fc.tuple(validRedditConfigArb).map(configs => configs),
|
||||
fc.tuple(validNewsApiConfigArb).map(configs => configs),
|
||||
// Pairs
|
||||
fc.tuple(validRssConfigArb, validRedditConfigArb).map(configs => configs),
|
||||
fc.tuple(validRssConfigArb, validNewsApiConfigArb).map(configs => configs),
|
||||
fc.tuple(validRedditConfigArb, validNewsApiConfigArb).map(configs => configs),
|
||||
// All three
|
||||
allSourceTypesConfigArb.map(configs => configs)
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// PROPERTY TESTS
|
||||
// ============================================
|
||||
|
||||
describe('All source types validate successfully', () => {
|
||||
it('RSS configurations with valid URLs pass validation', () => {
|
||||
fc.assert(
|
||||
fc.property(validRssConfigArb, (config) => {
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('Reddit configurations with valid URLs and subreddits pass validation', () => {
|
||||
fc.assert(
|
||||
fc.property(validRedditConfigArb, (config) => {
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('News API configurations with valid URLs and API keys pass validation', () => {
|
||||
fc.assert(
|
||||
fc.property(validNewsApiConfigArb, (config) => {
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple source types can coexist', () => {
|
||||
it('all three source types (RSS, Reddit, news_api) can be validated independently', () => {
|
||||
fc.assert(
|
||||
fc.property(allSourceTypesConfigArb, ([rssConfig, redditConfig, newsApiConfig]) => {
|
||||
// Validate each source type independently
|
||||
const rssResult: ContentSourceValidationResult = validateContentSourceConfig(rssConfig);
|
||||
const redditResult: ContentSourceValidationResult = validateContentSourceConfig(redditConfig);
|
||||
const newsApiResult: ContentSourceValidationResult = validateContentSourceConfig(newsApiConfig);
|
||||
|
||||
// All should pass validation
|
||||
expect(rssResult.valid).toBe(true);
|
||||
expect(redditResult.valid).toBe(true);
|
||||
expect(newsApiResult.valid).toBe(true);
|
||||
|
||||
// All should have no errors
|
||||
expect(rssResult.errors.length).toBe(0);
|
||||
expect(redditResult.errors.length).toBe(0);
|
||||
expect(newsApiResult.errors.length).toBe(0);
|
||||
|
||||
// Each should have the correct type
|
||||
expect(rssConfig.type).toBe('rss');
|
||||
expect(redditConfig.type).toBe('reddit');
|
||||
expect(newsApiConfig.type).toBe('news_api');
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('any subset of source types can be validated together', () => {
|
||||
fc.assert(
|
||||
fc.property(sourceTypeSubsetArb, (configs) => {
|
||||
// Validate all configs in the subset
|
||||
const results = configs.map(config => ({
|
||||
config,
|
||||
result: validateContentSourceConfig(config) as ContentSourceValidationResult,
|
||||
}));
|
||||
|
||||
// All should pass validation
|
||||
for (const { config, result } of results) {
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
expect(SUPPORTED_SOURCE_TYPES).toContain(config.type);
|
||||
}
|
||||
|
||||
// Verify we have at least one config
|
||||
expect(configs.length).toBeGreaterThan(0);
|
||||
expect(configs.length).toBeLessThanOrEqual(3);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Source type identification', () => {
|
||||
it('each source type is correctly identified as supported', () => {
|
||||
fc.assert(
|
||||
fc.property(allSourceTypesConfigArb, ([rssConfig, redditConfig, newsApiConfig]) => {
|
||||
// All types should be recognized as supported
|
||||
expect(isSupportedSourceType(rssConfig.type)).toBe(true);
|
||||
expect(isSupportedSourceType(redditConfig.type)).toBe(true);
|
||||
expect(isSupportedSourceType(newsApiConfig.type)).toBe(true);
|
||||
|
||||
// Types should be distinct
|
||||
const types = new Set([rssConfig.type, redditConfig.type, newsApiConfig.type]);
|
||||
expect(types.size).toBe(3);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('SUPPORTED_SOURCE_TYPES contains all three types', () => {
|
||||
expect(SUPPORTED_SOURCE_TYPES).toContain('rss');
|
||||
expect(SUPPORTED_SOURCE_TYPES).toContain('reddit');
|
||||
expect(SUPPORTED_SOURCE_TYPES).toContain('news_api');
|
||||
expect(SUPPORTED_SOURCE_TYPES.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Source type-specific validation', () => {
|
||||
it('RSS sources do not require subreddit or apiKey', () => {
|
||||
fc.assert(
|
||||
fc.property(validRssConfigArb, (config) => {
|
||||
// RSS config should not have subreddit or apiKey
|
||||
const configWithoutOptionals = {
|
||||
type: config.type,
|
||||
url: config.url,
|
||||
};
|
||||
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(configWithoutOptionals);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('Reddit sources require subreddit', () => {
|
||||
fc.assert(
|
||||
fc.property(validRedditConfigArb, (config) => {
|
||||
// Reddit config with subreddit should pass
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
expect(result.valid).toBe(true);
|
||||
|
||||
// Reddit config without subreddit should fail
|
||||
const configWithoutSubreddit = {
|
||||
type: config.type,
|
||||
url: config.url,
|
||||
};
|
||||
const resultWithoutSubreddit: ContentSourceValidationResult = validateContentSourceConfig(configWithoutSubreddit);
|
||||
expect(resultWithoutSubreddit.valid).toBe(false);
|
||||
expect(resultWithoutSubreddit.errors.some(e => e.includes('Subreddit') || e.includes('subreddit'))).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('News API sources require apiKey', () => {
|
||||
fc.assert(
|
||||
fc.property(validNewsApiConfigArb, (config) => {
|
||||
// News API config with apiKey should pass
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
expect(result.valid).toBe(true);
|
||||
|
||||
// News API config without apiKey should fail
|
||||
const configWithoutApiKey = {
|
||||
type: config.type,
|
||||
url: config.url,
|
||||
};
|
||||
const resultWithoutApiKey: ContentSourceValidationResult = validateContentSourceConfig(configWithoutApiKey);
|
||||
expect(resultWithoutApiKey.valid).toBe(false);
|
||||
expect(resultWithoutApiKey.errors.some(e => e.includes('API key') || e.includes('apiKey'))).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple sources with same type', () => {
|
||||
it('multiple RSS sources can all be valid', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(validRssConfigArb, { minLength: 2, maxLength: 5 }),
|
||||
(configs) => {
|
||||
// All RSS configs should be valid
|
||||
for (const config of configs) {
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(config.type).toBe('rss');
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('multiple Reddit sources can all be valid', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(validRedditConfigArb, { minLength: 2, maxLength: 5 }),
|
||||
(configs) => {
|
||||
// All Reddit configs should be valid
|
||||
for (const config of configs) {
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(config.type).toBe('reddit');
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('multiple News API sources can all be valid', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(validNewsApiConfigArb, { minLength: 2, maxLength: 5 }),
|
||||
(configs) => {
|
||||
// All News API configs should be valid
|
||||
for (const config of configs) {
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(config.type).toBe('news_api');
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mixed source types collection', () => {
|
||||
it('a collection of mixed source types all validate correctly', () => {
|
||||
// Generator for a mixed collection of 1-10 sources of various types
|
||||
const mixedSourcesArb = fc.array(
|
||||
fc.oneof(validRssConfigArb, validRedditConfigArb, validNewsApiConfigArb),
|
||||
{ minLength: 1, maxLength: 10 }
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(mixedSourcesArb, (configs) => {
|
||||
// Track which types we've seen
|
||||
const seenTypes = new Set<string>();
|
||||
|
||||
// All configs should be valid
|
||||
for (const config of configs) {
|
||||
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
|
||||
// Track the type
|
||||
seenTypes.add(config.type);
|
||||
|
||||
// Type should be one of the supported types
|
||||
expect(SUPPORTED_SOURCE_TYPES).toContain(config.type);
|
||||
}
|
||||
|
||||
// We should have at least one source
|
||||
expect(configs.length).toBeGreaterThan(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('source configurations preserve their type after validation', () => {
|
||||
fc.assert(
|
||||
fc.property(allSourceTypesConfigArb, ([rssConfig, redditConfig, newsApiConfig]) => {
|
||||
// Validate each config
|
||||
validateContentSourceConfig(rssConfig);
|
||||
validateContentSourceConfig(redditConfig);
|
||||
validateContentSourceConfig(newsApiConfig);
|
||||
|
||||
// Types should still be correct after validation
|
||||
expect(rssConfig.type).toBe('rss');
|
||||
expect(redditConfig.type).toBe('reddit');
|
||||
expect(newsApiConfig.type).toBe('news_api');
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user