diff --git a/package-lock.json b/package-lock.json index 9ddc529a..d656d53d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -169,7 +169,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -192,7 +191,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -802,7 +800,6 @@ "node_modules/@opentelemetry/api": { "version": "1.8.0", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -830,7 +827,6 @@ "node_modules/@opentelemetry/core": { "version": "1.24.1", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "1.24.1" }, @@ -844,7 +840,6 @@ "node_modules/@opentelemetry/instrumentation": { "version": "0.51.1", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.51.1", "@types/shimmer": "^1.0.2", @@ -1221,7 +1216,6 @@ "node_modules/@opentelemetry/semantic-conventions": { "version": "1.24.1", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -1608,7 +1602,6 @@ "node_modules/@sentry/node/node_modules/@opentelemetry/api": { "version": "1.9.0", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2053,7 +2046,6 @@ "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", @@ -2423,7 +2415,6 @@ "node_modules/acorn": { "version": "8.11.3", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3019,7 +3010,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5207,7 +5197,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5370,7 +5359,6 @@ "version": "5.6.2", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5456,7 +5444,6 @@ "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5573,7 +5560,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index bd1344d7..05bc160f 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -39,6 +39,15 @@ for route in $(curl -s localhost:3000/v1/routes | jq -r '.[].path'); do # we can run these, because they're ICS, not GCal ;; + "/v1/news/named/stolaf" | "/v1/news/named/krlx") + # Validate news endpoints with Zod schema + echo "validating $route with Zod schema" + RESPONSE=$(curl --silent --fail "localhost:3000$route") + # Validate against the Zod schema + echo "$RESPONSE" | node dist/scripts/validate-schema.js "$route" + continue + ;; + "/v1/calendar/"* | "/v1/convos/upcoming") echo "skip because we don't have authorization during smoke tests" continue diff --git a/scripts/validate-schema.ts b/scripts/validate-schema.ts new file mode 100644 index 00000000..78733697 --- /dev/null +++ b/scripts/validate-schema.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/** + * Script to validate JSON responses against Zod schemas + * Usage: node dist/scripts/validate-schema.js + * + * Example: + * echo '{"title": "test"}' | node dist/scripts/validate-schema.js feeds/types FeedItemSchema + */ + +import {readFileSync} from 'fs' +import {z} from 'zod' + +// Schema registry - maps endpoint patterns to their schemas +const SCHEMA_REGISTRY: Record = { + '/v1/news/named/stolaf': {module: 'feeds/types', schema: 'FeedItemSchema', isArray: true}, + '/v1/news/named/mess': {module: 'feeds/types', schema: 'FeedItemSchema', isArray: true}, + '/v1/news/named/krlx': {module: 'feeds/types', schema: 'FeedItemSchema', isArray: true}, + '/v1/news/named/oleville': {module: 'feeds/types', schema: 'FeedItemSchema', isArray: true}, +} + +async function main() { + const endpoint = process.argv[2] + const jsonInput = process.argv[3] ?? readFileSync(0, 'utf-8') // Read from stdin if not provided + + if (!endpoint) { + console.error('Usage: validate-schema [json-data]') + console.error('If json-data is not provided, reads from stdin') + process.exit(1) + } + + const schemaInfo = SCHEMA_REGISTRY[endpoint] + if (!schemaInfo) { + console.error(`No schema registered for endpoint: ${endpoint}`) + console.error(`Registered endpoints: ${Object.keys(SCHEMA_REGISTRY).join(', ')}`) + process.exit(1) + } + + try { + // Dynamically import the schema module + const modulePath = `../source/${schemaInfo.module}.js` + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const module = await import(modulePath) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + let schema = module[schemaInfo.schema] + if (!schema) { + console.error(`Schema ${schemaInfo.schema} not found in module ${schemaInfo.module}`) + process.exit(1) + } + + // Wrap in array if needed + if (schemaInfo.isArray) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + schema = z.array(schema) + } + + // Parse the JSON input + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const data = JSON.parse(jsonInput) + + // Validate against schema + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + schema.parse(data) + + console.log('✓ Validation successful') + process.exit(0) + } catch (error) { + if (error instanceof z.ZodError) { + console.error('✗ Schema validation failed:') + console.error(JSON.stringify(error.errors, null, 2)) + } else if (error instanceof SyntaxError) { + console.error('✗ Invalid JSON input') + } else { + console.error('✗ Validation error:', error) + } + process.exit(1) + } +} + +void main() diff --git a/source/ccci-carleton-college/v1/menu.test.ts b/source/ccci-carleton-college/v1/menu.test.ts index 2aff92b9..f30bfbce 100644 --- a/source/ccci-carleton-college/v1/menu.test.ts +++ b/source/ccci-carleton-college/v1/menu.test.ts @@ -31,13 +31,17 @@ const cafeMenuFunctions: Record Pro } as const for (const cafe of keysOf(menu.CAFE_URLS)) { - test(`${cafe} cafe endpoint should return a BamcoCafeInfo struct`, async () => { - const ctx = {cacheControl: noop, body: null} as Context - await cafeInfoFunctions[cafe](ctx) - expect(() => CafeInfoResponseSchema.parse(ctx.body)).not.toThrow() - }) + test( + `${cafe} cafe endpoint should return a BamcoCafeInfo struct`, + {timeout: 15_000}, + async () => { + const ctx = {cacheControl: noop, body: null} as Context + await cafeInfoFunctions[cafe](ctx) + expect(() => CafeInfoResponseSchema.parse(ctx.body)).not.toThrow() + }, + ) - test(`${cafe} menu endpoint should return a CafeMenu struct`, async () => { + test(`${cafe} menu endpoint should return a CafeMenu struct`, {timeout: 15_000}, async () => { const ctx = {cacheControl: noop, body: null} as Context await cafeMenuFunctions[cafe](ctx) expect(() => CafeMenuResponseSchema.parse(ctx.body)).not.toThrow() diff --git a/source/ccci-stolaf-college/v1/menu.test.ts b/source/ccci-stolaf-college/v1/menu.test.ts index 2aff92b9..f30bfbce 100644 --- a/source/ccci-stolaf-college/v1/menu.test.ts +++ b/source/ccci-stolaf-college/v1/menu.test.ts @@ -31,13 +31,17 @@ const cafeMenuFunctions: Record Pro } as const for (const cafe of keysOf(menu.CAFE_URLS)) { - test(`${cafe} cafe endpoint should return a BamcoCafeInfo struct`, async () => { - const ctx = {cacheControl: noop, body: null} as Context - await cafeInfoFunctions[cafe](ctx) - expect(() => CafeInfoResponseSchema.parse(ctx.body)).not.toThrow() - }) + test( + `${cafe} cafe endpoint should return a BamcoCafeInfo struct`, + {timeout: 15_000}, + async () => { + const ctx = {cacheControl: noop, body: null} as Context + await cafeInfoFunctions[cafe](ctx) + expect(() => CafeInfoResponseSchema.parse(ctx.body)).not.toThrow() + }, + ) - test(`${cafe} menu endpoint should return a CafeMenu struct`, async () => { + test(`${cafe} menu endpoint should return a CafeMenu struct`, {timeout: 15_000}, async () => { const ctx = {cacheControl: noop, body: null} as Context await cafeMenuFunctions[cafe](ctx) expect(() => CafeMenuResponseSchema.parse(ctx.body)).not.toThrow() diff --git a/source/feeds/wp-json.test.ts b/source/feeds/wp-json.test.ts new file mode 100644 index 00000000..639f13ab --- /dev/null +++ b/source/feeds/wp-json.test.ts @@ -0,0 +1,190 @@ +import {test, expect} from 'vitest' +import { + convertWpJsonItemToStory, + WpJsonFeedEntrySchema, + type WpJsonFeedEntryType, +} from './wp-json.js' + +test('WpJsonFeedEntrySchema should parse items with missing featuredmedia fields', () => { + const itemWithMissingFields = { + _embedded: { + author: [{id: 1, name: 'Test Author'}], + 'wp:featuredmedia': [ + { + id: 123, + // missing media_type, media_details, and source_url + }, + ], + 'wp:term': [[{taxonomy: 'category', name: 'News'}]], + }, + author: 1, + featured_media: 123, + content: {rendered: '

Test content

'}, + excerpt: {rendered: '

Test excerpt

'}, + title: {rendered: 'Test Title'}, + date_gmt: '2024-01-01T12:00:00', + link: 'https://example.com/post', + } + + expect(() => WpJsonFeedEntrySchema.parse(itemWithMissingFields)).not.toThrow() +}) + +test('WpJsonFeedEntrySchema should parse items with complete featuredmedia fields', () => { + const itemWithCompleteFields = { + _embedded: { + author: [{id: 1, name: 'Test Author'}], + 'wp:featuredmedia': [ + { + id: 123, + media_type: 'image', + media_details: { + sizes: { + medium_large: { + source_url: 'https://example.com/image-medium.jpg', + }, + }, + }, + source_url: 'https://example.com/image.jpg', + }, + ], + 'wp:term': [[{taxonomy: 'category', name: 'News'}]], + }, + author: 1, + featured_media: 123, + content: {rendered: '

Test content

'}, + excerpt: {rendered: '

Test excerpt

'}, + title: {rendered: 'Test Title'}, + date_gmt: '2024-01-01T12:00:00', + link: 'https://example.com/post', + } + + expect(() => WpJsonFeedEntrySchema.parse(itemWithCompleteFields)).not.toThrow() +}) + +test('WpJsonFeedEntrySchema should parse items with null wp:featuredmedia', () => { + const itemWithNullFeaturedMedia = { + _embedded: { + author: [{id: 1, name: 'Test Author'}], + 'wp:featuredmedia': null, + 'wp:term': [[{taxonomy: 'category', name: 'News'}]], + }, + author: 1, + content: {rendered: '

Test content

'}, + excerpt: {rendered: '

Test excerpt

'}, + title: {rendered: 'Test Title'}, + date_gmt: '2024-01-01T12:00:00', + link: 'https://example.com/post', + } + + expect(() => WpJsonFeedEntrySchema.parse(itemWithNullFeaturedMedia)).not.toThrow() +}) + +test('convertWpJsonItemToStory should handle missing featuredmedia fields', () => { + const itemWithMissingFields: WpJsonFeedEntryType = { + _embedded: { + author: [{id: 1, name: 'Test Author'}], + 'wp:featuredmedia': [ + { + id: 123, + // missing media_type, media_details, and source_url + }, + ], + 'wp:term': [[{taxonomy: 'category', name: 'News'}]], + }, + author: 1, + featured_media: 123, + content: {rendered: '

Test content

'}, + excerpt: {rendered: '

Test excerpt

'}, + title: {rendered: 'Test Title'}, + date_gmt: '2024-01-01T12:00:00', + link: 'https://example.com/post', + } + + // Should not throw and featuredImage should be null when fields are missing + const result = convertWpJsonItemToStory(itemWithMissingFields) + expect(result.featuredImage).toBe(null) +}) + +test('convertWpJsonItemToStory should handle complete featuredmedia fields', () => { + const itemWithCompleteFields: WpJsonFeedEntryType = { + _embedded: { + author: [{id: 1, name: 'Test Author'}], + 'wp:featuredmedia': [ + { + id: 123, + media_type: 'image', + media_details: { + sizes: { + medium_large: { + source_url: 'https://example.com/image-medium.jpg', + }, + }, + }, + source_url: 'https://example.com/image.jpg', + }, + ], + 'wp:term': [[{taxonomy: 'category', name: 'News'}]], + }, + author: 1, + featured_media: 123, + content: {rendered: '

Test content

'}, + excerpt: {rendered: '

Test excerpt

'}, + title: {rendered: 'Test Title'}, + date_gmt: '2024-01-01T12:00:00', + link: 'https://example.com/post', + } + + const result = convertWpJsonItemToStory(itemWithCompleteFields) + expect(result.featuredImage).toBe('https://example.com/image-medium.jpg') +}) + +test('convertWpJsonItemToStory should handle null wp:featuredmedia', () => { + const itemWithNullFeaturedMedia: WpJsonFeedEntryType = { + _embedded: { + author: [{id: 1, name: 'Test Author'}], + 'wp:featuredmedia': null, + 'wp:term': [[{taxonomy: 'category', name: 'News'}]], + }, + author: 1, + content: {rendered: '

Test content

'}, + excerpt: {rendered: '

Test excerpt

'}, + title: {rendered: 'Test Title'}, + date_gmt: '2024-01-01T12:00:00', + link: 'https://example.com/post', + } + + const result = convertWpJsonItemToStory(itemWithNullFeaturedMedia) + expect(result.featuredImage).toBe(null) +}) + +test('WpJsonFeedEntrySchema parsing should work with real-world incomplete data', () => { + // This simulates what WordPress might return when a featured media + // entry exists but has incomplete information + const incompleteData = { + _embedded: { + author: [{id: 1, name: 'Test Author'}], + 'wp:featuredmedia': [ + { + id: 123, + // All other fields missing + }, + ], + 'wp:term': [[{taxonomy: 'category', name: 'News'}]], + }, + author: 1, + featured_media: 123, + content: {rendered: '

Test content

'}, + excerpt: {rendered: '

Test excerpt

'}, + title: {rendered: 'Test Title'}, + date_gmt: '2024-01-01T12:00:00', + link: 'https://example.com/post', + } + + // Should parse without error + const parsed = WpJsonFeedEntrySchema.parse(incompleteData) + expect(parsed).toBeDefined() + + // Convert should also work and return null for featuredImage + const converted = convertWpJsonItemToStory(parsed) + expect(converted.featuredImage).toBe(null) +}) diff --git a/source/feeds/wp-json.ts b/source/feeds/wp-json.ts index 174f0a11..a5490b78 100644 --- a/source/feeds/wp-json.ts +++ b/source/feeds/wp-json.ts @@ -5,8 +5,8 @@ import type {SearchParamsOption} from 'ky' import {z} from 'zod' import moment from 'moment' -type WpJsonFeedEntryType = z.infer -const WpJsonFeedEntrySchema = z.object({ +export type WpJsonFeedEntryType = z.infer +export const WpJsonFeedEntrySchema = z.object({ _embedded: z.optional( z.object({ author: z.array(z.object({id: z.unknown(), name: z.string().or(z.undefined())})).optional(), @@ -14,11 +14,13 @@ const WpJsonFeedEntrySchema = z.object({ .array( z.object({ id: z.unknown(), - media_type: z.union([z.literal('image'), z.string()]), - media_details: z.object({ - sizes: z.optional(z.record(z.object({source_url: z.string().url()}))), - }), - source_url: z.string().url(), + media_type: z.union([z.literal('image'), z.string()]).optional(), + media_details: z + .object({ + sizes: z.optional(z.record(z.object({source_url: z.string().url()}))), + }) + .optional(), + source_url: z.string().url().optional(), }), ) .nullable() @@ -62,8 +64,9 @@ export function convertWpJsonItemToStory(item: WpJsonFeedEntryType) { if (featuredMediaInfo) { featuredImage = - featuredMediaInfo.media_details.sizes?.['medium_large']?.source_url ?? - featuredMediaInfo.source_url + featuredMediaInfo.media_details?.sizes?.['medium_large']?.source_url ?? + featuredMediaInfo.source_url ?? + null } }