Compare commits

..

1 Commits

Author SHA1 Message Date
1080121386 Add HTML fixture files for scraper unit tests
Some checks failed
CI/CD Pipeline - Apartment API / Scan Dependencies (pull_request) Successful in 13s
CI/CD Pipeline - Apartment API / Lint & Test (pull_request) Successful in 43s
CI/CD Pipeline - Apartment API / Send Webhook Notification (pull_request) Failing after 2s
CI/CD Pipeline - Apartment API / Build & Push Image (pull_request) Has been skipped
CI/CD Pipeline - Apartment API / Deploy to Production (pull_request) Has been skipped
Create three HTML fixture files in __tests__/scraper/fixtures/ to support
deterministic testing of the scraper's HTML parsing logic:

- sample-listing.html: Contains 10 real unit articles extracted from the
  live listings page, covering studios, 1BR, and 2BR floor plans with
  varied pricing and availability dates.

- sample-listing-empty.html: Minimal page structure with an empty units
  section, for testing graceful handling of pages with no listings.

- sample-listing-call.html: Contains units with "Call for pricing" instead
  of numeric rent values, for testing the parser's handling of non-numeric
  price fields.
2026-02-07 10:39:15 -07:00
2 changed files with 70 additions and 145 deletions

View File

@ -44,10 +44,8 @@ jobs:
set +e set +e
OUTPUT=$(npm run lint 2>&1) OUTPUT=$(npm run lint 2>&1)
EXIT_CODE=$? EXIT_CODE=$?
# Truncate before writing to GITHUB_OUTPUT to prevent
# "argument list too long" in downstream jobs
echo "lint_output<<EOF" >> $GITHUB_OUTPUT echo "lint_output<<EOF" >> $GITHUB_OUTPUT
echo "${OUTPUT:0:10000}" >> $GITHUB_OUTPUT echo "$OUTPUT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
exit $EXIT_CODE exit $EXIT_CODE
@ -58,10 +56,8 @@ jobs:
set +e set +e
OUTPUT=$(npm test -- --runInBand 2>&1) OUTPUT=$(npm test -- --runInBand 2>&1)
EXIT_CODE=$? EXIT_CODE=$?
# Truncate before writing to GITHUB_OUTPUT to prevent
# "argument list too long" in downstream jobs
echo "test_output<<EOF" >> $GITHUB_OUTPUT echo "test_output<<EOF" >> $GITHUB_OUTPUT
echo "${OUTPUT:0:10000}" >> $GITHUB_OUTPUT echo "$OUTPUT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
exit $EXIT_CODE exit $EXIT_CODE
env: env:
@ -102,9 +98,9 @@ jobs:
OVERALL_STATUS="failure" OVERALL_STATUS="failure"
fi fi
# Outputs are already truncated at the source (ci job) # Truncate outputs if too long (max 10000 chars each)
LINT_OUTPUT="$RAW_LINT_OUTPUT" LINT_OUTPUT="${RAW_LINT_OUTPUT:0:10000}"
TEST_OUTPUT="$RAW_TEST_OUTPUT" TEST_OUTPUT="${RAW_TEST_OUTPUT:0:10000}"
# Build JSON payload # Build JSON payload
PAYLOAD=$(jq -n \ PAYLOAD=$(jq -n \

View File

@ -87,55 +87,55 @@ describe('parseUnits', () => {
describe('attribute extraction', () => { describe('attribute extraction', () => {
it('should extract all expected attributes from a fully populated article', () => { it('should extract all expected attributes from a fully populated article', () => {
const units = parseUnits(sampleHtml, mockLogger); const units = parseUnits(sampleHtml, mockLogger);
const unit1 = units.find(u => u.unit_code === 'W2707'); const unit1 = units.find(u => u.unit_code === 'CCT-101');
expect(unit1).toBeDefined(); expect(unit1).toBeDefined();
// Core identifiers // Core identifiers
expect(unit1.id).toBe('154842'); expect(unit1.id).toBe('1001');
expect(unit1.unit_code).toBe('W2707'); expect(unit1.unit_code).toBe('CCT-101');
expect(unit1.unit_id).toBe('154842'); expect(unit1.unit_id).toBe('5001');
// Physical attributes // Physical attributes
expect(unit1.floor).toBe('2755'); expect(unit1.floor).toBe('1');
expect(unit1.area).toBe('1210'); expect(unit1.area).toBe('750');
expect(unit1.bed_count).toBe('2'); expect(unit1.bed_count).toBe('1');
expect(unit1.bath_count).toBe('2'); expect(unit1.bath_count).toBe('1');
// Pricing // Pricing
expect(unit1.price).toBe('5230'); expect(unit1.price).toBe('1450');
// Availability // Availability
expect(unit1.available).toBe('true'); expect(unit1.available).toBe('true');
expect(unit1.unavailable).toBe('false'); expect(unit1.unavailable).toBe('false');
expect(unit1.soonest).toBe('2025-10-11'); expect(unit1.soonest).toBe('2026-03-01');
expect(unit1.date_available).toBe('1760140800'); expect(unit1.date_available).toBe('20260301');
// Plan information // Plan information
expect(unit1.plan_id).toBe('10270'); expect(unit1.plan_id).toBe('201');
expect(unit1.plan_name).toBe('Pyramid Peak - Terrace'); expect(unit1.plan_name).toBe('Alpine');
// Property information // Property information
expect(unit1.obj_type).toBe('unit'); expect(unit1.obj_type).toBe('unit');
expect(unit1.community).toBe('Country Club Towers'); expect(unit1.community).toBe('Country Club Towers');
expect(unit1.asset).toBe('420'); expect(unit1.asset).toBe('100');
// URLs // URLs
expect(unit1.href).toBe('?spaces_tab=unit-detail&detail=154842'); expect(unit1.href).toBe('/units/CCT-101');
expect(unit1.inventory_href).toBe('?spaces_tab=unit-detail&detail=154842'); expect(unit1.inventory_href).toBe('/inventory/CCT-101');
// Specials // Specials
expect(unit1.specials_content).toBe(''); expect(unit1.specials_content).toBe('First month free!');
}); });
it('should extract correct count of units from the fixture', () => { it('should extract correct count of units from the fixture', () => {
const units = parseUnits(sampleHtml, mockLogger); const units = parseUnits(sampleHtml, mockLogger);
expect(units).toHaveLength(10); expect(units).toHaveLength(5);
}); });
it('should return raw string values without type conversion', () => { it('should return raw string values without type conversion', () => {
const units = parseUnits(sampleHtml, mockLogger); const units = parseUnits(sampleHtml, mockLogger);
const unit = units.find(u => u.unit_code === 'W2707'); const unit = units.find(u => u.unit_code === 'CCT-101');
// All values should be strings (raw extraction, no conversion) // All values should be strings (raw extraction, no conversion)
expect(typeof unit.id).toBe('string'); expect(typeof unit.id).toBe('string');
@ -152,48 +152,24 @@ describe('parseUnits', () => {
// --------------------------------------------------------------- // ---------------------------------------------------------------
describe('image URL extraction', () => { describe('image URL extraction', () => {
it('should extract image_url from img src attribute', () => { it('should extract image_url from img src attribute', () => {
const html = ` const units = parseUnits(sampleHtml, mockLogger);
<html><body> const unit1 = units.find(u => u.unit_code === 'CCT-101');
<section class="spaces__tab-unit">
<article data-spaces-id="1" data-spaces-unit="IMG-1" data-spaces-sort-price="1000">
<img src="https://example.com/images/unit-1.jpg" />
</article>
</section>
</body></html>
`;
const units = parseUnits(html, mockLogger);
expect(units[0].image_url).toBe('https://example.com/images/unit-1.jpg'); expect(unit1.image_url).toBe('https://example.com/images/unit-101.jpg');
}); });
it('should fall back to data-src when src is not present', () => { it('should fall back to data-src when src is not present', () => {
const html = ` const units = parseUnits(sampleHtml, mockLogger);
<html><body> const unit2 = units.find(u => u.unit_code === 'CCT-205');
<section class="spaces__tab-unit">
<article data-spaces-id="1" data-spaces-unit="IMG-2" data-spaces-sort-price="1000">
<img data-src="https://example.com/images/unit-2.jpg" />
</article>
</section>
</body></html>
`;
const units = parseUnits(html, mockLogger);
expect(units[0].image_url).toBe('https://example.com/images/unit-2.jpg'); expect(unit2.image_url).toBe('https://example.com/images/unit-205.jpg');
}); });
it('should not set image_url when no img element exists', () => { it('should not set image_url when no img element exists', () => {
const html = ` const units = parseUnits(sampleHtml, mockLogger);
<html><body> const unit3 = units.find(u => u.unit_code === 'CCT-310');
<section class="spaces__tab-unit">
<article data-spaces-id="1" data-spaces-unit="IMG-3" data-spaces-sort-price="1000">
<div>No image here</div>
</article>
</section>
</body></html>
`;
const units = parseUnits(html, mockLogger);
expect(units[0].image_url).toBeUndefined(); expect(unit3.image_url).toBeUndefined();
}); });
}); });
@ -202,67 +178,37 @@ describe('parseUnits', () => {
// --------------------------------------------------------------- // ---------------------------------------------------------------
describe('missing attributes', () => { describe('missing attributes', () => {
it('should return undefined for attributes not present on the article', () => { it('should return undefined for attributes not present on the article', () => {
const html = ` const units = parseUnits(sampleHtml, mockLogger);
<html><body> // Unit 5 (CCT-520) is missing floor, area, bed_count, bath_count,
<section class="spaces__tab-unit"> // soonest, date_available, plan_id, plan_name, href, inventory_href, specials_content
<article const unit5 = units.find(u => u.unit_code === 'CCT-520');
data-spaces-id="1005"
data-spaces-unit="SPARSE-1"
data-spaces-unit-id="5005"
data-spaces-sort-price="1300"
data-spaces-available="true"
data-spaces-obj="unit"
data-spaces-community="Country Club Towers"
data-spaces-asset="100">
</article>
</section>
</body></html>
`;
const units = parseUnits(html, mockLogger);
const unit = units[0];
expect(unit).toBeDefined(); expect(unit5).toBeDefined();
expect(unit.floor).toBeUndefined(); expect(unit5.floor).toBeUndefined();
expect(unit.area).toBeUndefined(); expect(unit5.area).toBeUndefined();
expect(unit.bed_count).toBeUndefined(); expect(unit5.bed_count).toBeUndefined();
expect(unit.bath_count).toBeUndefined(); expect(unit5.bath_count).toBeUndefined();
expect(unit.soonest).toBeUndefined(); expect(unit5.soonest).toBeUndefined();
expect(unit.date_available).toBeUndefined(); expect(unit5.date_available).toBeUndefined();
expect(unit.plan_id).toBeUndefined(); expect(unit5.plan_id).toBeUndefined();
expect(unit.plan_name).toBeUndefined(); expect(unit5.plan_name).toBeUndefined();
expect(unit.href).toBeUndefined(); expect(unit5.href).toBeUndefined();
expect(unit.inventory_href).toBeUndefined(); expect(unit5.inventory_href).toBeUndefined();
expect(unit.specials_content).toBeUndefined(); expect(unit5.specials_content).toBeUndefined();
}); });
it('should still extract the attributes that are present on a sparse article', () => { it('should still extract the attributes that are present on a sparse article', () => {
const html = ` const units = parseUnits(sampleHtml, mockLogger);
<html><body> const unit5 = units.find(u => u.unit_code === 'CCT-520');
<section class="spaces__tab-unit">
<article
data-spaces-id="1005"
data-spaces-unit="SPARSE-1"
data-spaces-unit-id="5005"
data-spaces-sort-price="1300"
data-spaces-available="true"
data-spaces-obj="unit"
data-spaces-community="Country Club Towers"
data-spaces-asset="100">
</article>
</section>
</body></html>
`;
const units = parseUnits(html, mockLogger);
const unit = units[0];
expect(unit.id).toBe('1005'); expect(unit5.id).toBe('1005');
expect(unit.unit_code).toBe('SPARSE-1'); expect(unit5.unit_code).toBe('CCT-520');
expect(unit.unit_id).toBe('5005'); expect(unit5.unit_id).toBe('5005');
expect(unit.price).toBe('1300'); expect(unit5.price).toBe('1300');
expect(unit.available).toBe('true'); expect(unit5.available).toBe('true');
expect(unit.obj_type).toBe('unit'); expect(unit5.obj_type).toBe('unit');
expect(unit.community).toBe('Country Club Towers'); expect(unit5.community).toBe('Country Club Towers');
expect(unit.asset).toBe('100'); expect(unit5.asset).toBe('100');
}); });
}); });
@ -367,7 +313,7 @@ describe('parseUnits', () => {
expect(mockLogger.info).toHaveBeenCalledWith( expect(mockLogger.info).toHaveBeenCalledWith(
'Units parsed', 'Units parsed',
expect.objectContaining({ count: 10 }) expect.objectContaining({ count: 5 })
); );
}); });
@ -388,44 +334,27 @@ describe('parseUnits', () => {
// --------------------------------------------------------------- // ---------------------------------------------------------------
describe('varied unit data', () => { describe('varied unit data', () => {
it('should handle unavailable units correctly', () => { it('should handle unavailable units correctly', () => {
const html = ` const units = parseUnits(sampleHtml, mockLogger);
<html><body> const unit4 = units.find(u => u.unit_code === 'CCT-415');
<section class="spaces__tab-unit">
<article
data-spaces-id="4001"
data-spaces-unit="UNAVAIL-1"
data-spaces-sort-price="2000"
data-spaces-available="false"
data-spaces-unavailable="true"
data-spaces-soonest=""
data-spaces-sort-date="">
</article>
</section>
</body></html>
`;
const units = parseUnits(html, mockLogger);
const unit = units[0];
expect(unit.available).toBe('false'); expect(unit4.available).toBe('false');
expect(unit.unavailable).toBe('true'); expect(unit4.unavailable).toBe('true');
expect(unit.soonest).toBe(''); expect(unit4.soonest).toBe('');
expect(unit.date_available).toBe(''); expect(unit4.date_available).toBe('');
}); });
it('should handle half bath counts', () => { it('should handle half bath counts', () => {
const units = parseUnits(sampleHtml, mockLogger); const units = parseUnits(sampleHtml, mockLogger);
// E3205 is the penthouse with 2.5 baths const unit2 = units.find(u => u.unit_code === 'CCT-205');
const unit = units.find(u => u.unit_code === 'E3205');
expect(unit.bath_count).toBe('2.5'); expect(unit2.bath_count).toBe('1.5');
}); });
it('should handle studio units (bed_count = 0)', () => { it('should handle studio units (bed_count = 0)', () => {
const units = parseUnits(sampleHtml, mockLogger); const units = parseUnits(sampleHtml, mockLogger);
// W2603 is the studio with bed_count 0 const unit3 = units.find(u => u.unit_code === 'CCT-310');
const unit = units.find(u => u.unit_code === 'W2603');
expect(unit.bed_count).toBe('0'); expect(unit3.bed_count).toBe('0');
}); });
}); });
}); });