Skip to content

Commit 36112db

Browse files
committed
test: add tests
1 parent ccf3153 commit 36112db

File tree

4 files changed

+230
-7
lines changed

4 files changed

+230
-7
lines changed

bunfig.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ preload = ["./src/__tests__/setup.ts"]
33
coverageSkipTestFiles = true
44
coverageIgnoreSourcemaps = true
55
# https://github.com/oven-sh/bun/issues/7662
6-
coverageThreshold = { functions = 0, lines = 0.8 }
6+
coverageThreshold = { functions = 0, lines = 0.5 }

src/__tests__/abstract-scraper.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
22
import { AbstractScraper } from '@/abstract-scraper'
33
import { NotImplementedException } from '@/exceptions'
44
import { Logger } from '@/logger'
5+
import type { RecipeFields, RecipeObject } from '@/types/recipe.interface'
56

67
class DummyScraper extends AbstractScraper {
78
// implement required static host
@@ -97,3 +98,104 @@ describe('AbstractScraper utility methods', () => {
9798
})
9899
})
99100
})
101+
102+
// Test subclass overriding extract, canonicalUrl, language, links, and host
103+
class TestScraper extends AbstractScraper {
104+
static host(): string {
105+
return 'hostVal'
106+
}
107+
108+
// Provide no real HTML parsing
109+
extractors = {}
110+
private data: Partial<Record<keyof RecipeFields, unknown>>
111+
constructor(data: Partial<Record<keyof RecipeFields, unknown>>) {
112+
// html, url and options are unused because we override methods
113+
super('', '', { linksEnabled: true })
114+
this.data = data
115+
}
116+
117+
// Return mocked values for every field
118+
async extract<Key extends keyof RecipeFields>(
119+
field: Key,
120+
): Promise<RecipeFields[Key]> {
121+
return this.data[field] as RecipeFields[Key]
122+
}
123+
124+
override canonicalUrl(): string {
125+
return this.data.canonicalUrl as string
126+
}
127+
override language(): string {
128+
return this.data.language as string
129+
}
130+
override links(): RecipeFields['links'] {
131+
return this.data.links as RecipeFields['links']
132+
}
133+
}
134+
135+
describe('AbstractScraper.toObject', () => {
136+
it('returns a fully serialized RecipeObject', async () => {
137+
// Prepare mock values
138+
const mockValues: Partial<Record<keyof RecipeFields, unknown>> = {
139+
siteName: 'site',
140+
author: 'auth',
141+
title: 'ttl',
142+
image: 'img',
143+
description: 'desc',
144+
yields: '4 servings',
145+
totalTime: 30,
146+
cookTime: 10,
147+
prepTime: 20,
148+
cookingMethod: 'bake',
149+
ratings: 4.2,
150+
ratingsCount: 100,
151+
category: new Set(['cat1', 'cat2']),
152+
cuisine: new Set(['cui']),
153+
dietaryRestrictions: new Set(['veg']),
154+
equipment: new Set(['pan']),
155+
ingredients: new Set(['ing1', 'ing2']),
156+
instructions: new Set(['step1', 'step2']),
157+
keywords: new Set(['kw1']),
158+
nutrients: new Map([['cal', '200kcal']]),
159+
reviews: new Map([['rev1', 'Good']]),
160+
canonicalUrl: 'http://can.url',
161+
language: 'en-US',
162+
links: [{ href: 'http://link', text: 'LinkText' }],
163+
}
164+
165+
const scraper = new TestScraper(mockValues)
166+
const result = await scraper.toObject()
167+
168+
// Basic scalar fields
169+
const expectedRest = {
170+
host: 'hostVal',
171+
siteName: 'site',
172+
author: 'auth',
173+
title: 'ttl',
174+
image: 'img',
175+
canonicalUrl: 'http://can.url',
176+
language: 'en-US',
177+
links: [{ href: 'http://link', text: 'LinkText' }],
178+
description: 'desc',
179+
yields: '4 servings',
180+
totalTime: 30,
181+
cookTime: 10,
182+
prepTime: 20,
183+
cookingMethod: 'bake',
184+
ratings: 4.2,
185+
ratingsCount: 100,
186+
}
187+
188+
expect(result).toEqual({
189+
...expectedRest,
190+
category: ['cat1', 'cat2'],
191+
cuisine: ['cui'],
192+
dietaryRestrictions: ['veg'],
193+
equipment: ['pan'],
194+
ingredients: ['ing1', 'ing2'],
195+
instructions: ['step1', 'step2'],
196+
keywords: ['kw1'],
197+
nutrients: { cal: '200kcal' },
198+
reviews: { rev1: 'Good' },
199+
} as RecipeObject)
200+
})
201+
})
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { UnsupportedFieldException } from '@/exceptions'
3+
import type { RecipeFields } from '@/types/recipe.interface'
4+
import { isList } from '@/utils/ingredients'
5+
import { load } from 'cheerio'
6+
import { SchemaOrgPlugin } from '../index'
7+
8+
const minimalJsonLd = `
9+
<script type="application/ld+json">
10+
{
11+
"@graph": [
12+
{ "@type": "WebSite", "name": "MySite" },
13+
{
14+
"@type": "Recipe",
15+
"name": "RecipeName",
16+
"description": "Desc",
17+
"image": "https://img.jpg",
18+
"recipeIngredient": [" a ", "b"],
19+
"recipeInstructions": ["step1", "step2"],
20+
"recipeCategory": "Cat",
21+
"recipeYield": "4",
22+
"totalTime": "PT10M",
23+
"cookTime": "PT5M",
24+
"prepTime": "PT5M",
25+
"recipeCuisine": ["Cuisine"],
26+
"cookingMethod": "Bake",
27+
"aggregateRating": {
28+
"@type": "AggregateRating",
29+
"@id": "r1",
30+
"ratingValue": 4.5,
31+
"ratingCount": 10
32+
},
33+
"nutrition": { "calories": "100" },
34+
"keywords": ["kw1", " kw2 "],
35+
"suitableForDiet": "http://schema.org/Vegetarian"
36+
}
37+
]
38+
}
39+
</script>`
40+
41+
describe('SchemaOrgPlugin', () => {
42+
const $ = load(minimalJsonLd)
43+
const plugin = new SchemaOrgPlugin($)
44+
45+
it('supports known recipe fields', () => {
46+
// biome-ignore lint/complexity/useLiteralKeys: private use only
47+
const keys = Object.keys(plugin['extractors'])
48+
expect(plugin.supports('title')).toBe(keys.includes('title'))
49+
expect(plugin.supports('ingredients')).toBe(true)
50+
expect(plugin.supports('dietaryRestrictions')).toBe(true)
51+
expect(plugin.supports('unknown' as keyof RecipeFields)).toBe(false)
52+
})
53+
54+
it('extracts simple string fields', () => {
55+
expect(plugin.extract('siteName')).toBe('MySite')
56+
expect(plugin.extract('title')).toBe('RecipeName')
57+
expect(plugin.extract('description')).toBe('Desc')
58+
expect(plugin.extract('cookingMethod')).toBe('Bake')
59+
})
60+
61+
it('extracts image and validates URL', () => {
62+
expect(plugin.extract('image')).toBe('https://img.jpg')
63+
})
64+
65+
it('extracts numeric durations and times', () => {
66+
expect(plugin.extract('totalTime')).toBe(10)
67+
expect(plugin.extract('cookTime')).toBe(5)
68+
expect(plugin.extract('prepTime')).toBe(5)
69+
expect(plugin.extract('yields')).toBe('4')
70+
})
71+
72+
it('extracts ingredient and instruction lists', () => {
73+
const ingredients = plugin.extract('ingredients')
74+
75+
expect(isList(ingredients)).toBe(true)
76+
// @ts-expect-error
77+
expect(Array.from(ingredients)).toEqual(['a', 'b'])
78+
expect(Array.from(plugin.extract('instructions'))).toEqual([
79+
'step1',
80+
'step2',
81+
])
82+
})
83+
84+
it('extracts categorical and list fields', () => {
85+
expect(Array.from(plugin.extract('category'))).toEqual(['Cat'])
86+
expect(Array.from(plugin.extract('cuisine'))).toEqual(['Cuisine'])
87+
expect(Array.from(plugin.extract('keywords'))).toEqual(['kw1', 'kw2'])
88+
})
89+
90+
it('extracts ratings and counts correctly', () => {
91+
expect(plugin.extract('ratings')).toBe(4.5)
92+
expect(plugin.extract('ratingsCount')).toBe(10)
93+
})
94+
95+
it('extracts nutrients and dietary restrictions', () => {
96+
const nutrients = plugin.extract('nutrients')
97+
expect(nutrients.get('calories')).toBe('100')
98+
const diets = Array.from(plugin.extract('dietaryRestrictions'))
99+
expect(diets).toEqual(['Vegetarian'])
100+
})
101+
102+
it('throws UnsupportedFieldException for unsupported field', () => {
103+
expect(() => plugin.extract('foo' as keyof RecipeFields)).toThrow(
104+
UnsupportedFieldException,
105+
)
106+
})
107+
108+
it('throws SchemaOrgException for missing required field', () => {
109+
// JSON-LD missing 'name' for Recipe
110+
const badJson = `<script type="application/ld+json">{"@type":"Recipe"}</script>`
111+
const badPlugin = new SchemaOrgPlugin(load(badJson))
112+
expect(() => badPlugin.extract('title')).toThrow(
113+
/Missing required field: title/,
114+
)
115+
})
116+
117+
it('throws SchemaOrgException for invalid image', () => {
118+
const badImgJson = `<script type="application/ld+json">{"@type":"Recipe","image":"nope"}</script>`
119+
const badPlugin = new SchemaOrgPlugin(load(badImgJson))
120+
expect(() => badPlugin.extract('image')).toThrow(
121+
/Invalid value for "image": nope/,
122+
)
123+
})
124+
})

src/plugins/schema-org.extractor/index.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import {
2828
isOrganization,
2929
isPerson,
3030
isRecipe,
31-
isRestrictedDiet,
3231
isSchemaOrgData,
3332
isThingType,
3433
isWebPage,
@@ -257,8 +256,6 @@ export class SchemaOrgPlugin extends ExtractorPlugin {
257256
}
258257

259258
private processNonRecipeThing(obj: Thing) {
260-
this.logger.debug('Processing schema thing...', obj)
261-
262259
// Extract website info
263260
if (isWebSite(obj)) {
264261
this.websiteName = this.getSchemaTextValue(obj)
@@ -620,14 +617,14 @@ export class SchemaOrgPlugin extends ExtractorPlugin {
620617
}
621618

622619
public dietaryRestrictions(): RecipeFields['dietaryRestrictions'] {
623-
const restrictions = this.recipe.suitableForDiet
620+
const dietaryRestrictions = this.recipe.suitableForDiet
624621

625-
if (!isRestrictedDiet(restrictions)) {
622+
if (!dietaryRestrictions) {
626623
throw new SchemaOrgException('dietaryRestrictions')
627624
}
628625

629626
const restrictionList = new Set<string>()
630-
const list = this.schemaValueToList(restrictions)
627+
const list = this.schemaValueToList(dietaryRestrictions)
631628

632629
for (const item of list) {
633630
const value = item.replace(/^https?:\/\/schema\.org\//, '')

0 commit comments

Comments
 (0)