Files
linkder/packages/db/test/search.test.ts
T
serfaandClaude Opus 5 cb148c3dc8 Panel: an architecture diagram, store costs, and Spanish that reads as Spanish
The right-hand panel now answers the question the stack list provoked — what
does a client actually install? Capacitor, iOS and Android lead "Built with",
and a new SVG diagram shows the two ways in: customers through the installed
app, staff through the admin dashboard behind Cloudflare Access.

Costs were half-quoted. The stack named Mapbox, Twilio, Resend and Sentry and
priced none of them, so each now states the shape of its bill rather than a
figure that would be wrong at every scale. App store accounts are listed too,
in the client's own name for the same reason the hosting is.

Currency was the serious one. Every figure is US dollars and the panel wrote
them as "$59", but this launches in Mexico, where "$" is the peso — the Spanish
column understated the running cost roughly seventeenfold to the only reader
who matters. money() now prints "$59" in English and "59 USD" in Spanish.

The rest of the Spanish was a literal translation of dense English: peninsular
vocabulary in a Mexican market, two stray vosotros forms among the tuteo, and
calques that were not grammatical — "aplicada en ninguna parte", "todo lo
discontinuo" for dashed lines. Reviewed all 126 strings. The architecture note
was 120 words in one block covering five topics; it is now two paragraphs, one
idea each, and the PostGIS sentence went because "What it does" already makes
that promise in language a client can act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:57:05 -04:00

150 lines
5.9 KiB
TypeScript

/**
* Integration test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkdr/db test
*
* Search is a second door onto the same supply as the deck, so the test that
* matters most is the parity one: a pro who can be found here must be a pro who
* could be swiped there. That invariant is why `eligiblePro()` exists.
*/
import { config } from 'dotenv';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('../src/client');
const { searchPros } = await import('../src/queries/search');
const { getShowcaseDeck } = await import('../src/queries/deck');
/** The seed places every pro relative to this point. */
const CENTRE = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332),
};
let plumberId: string;
beforeAll(async () => {
const [plumber] = await db.execute<{ id: string }>(
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
);
if (!plumber) throw new Error('plumber category missing from seed');
plumberId = plumber.id;
// Seeded skills are empty, so text search has nothing to match until we give
// one pro something to find. Sergio Fabela is 1.1 km from the centre.
await db.execute(sql`
UPDATE pro_profiles SET skills = ARRAY['Underfloor heating', 'Emergency callouts']
WHERE user_id = (SELECT id FROM users WHERE name = 'Sergio Fabela')
`);
});
afterAll(async () => {
await db.execute(sql`
UPDATE pro_profiles SET skills = '{}'::text[]
WHERE user_id = (SELECT id FROM users WHERE name = 'Sergio Fabela')
`);
await closePool();
});
describe('searchPros', () => {
it('returns eligible pros with no query at all', async () => {
const results = await searchPros(db, { ...CENTRE, limit: 50 });
expect(results.length).toBeGreaterThan(0);
});
it('never returns anyone the shop window would not show', async () => {
// The parity invariant. If these two ever disagree, one surface has become
// the way around the other.
const found = await searchPros(db, { ...CENTRE, limit: 50 });
const showcase = await getShowcaseDeck(db, { ...CENTRE, limit: 100 });
const shownIds = new Set(showcase.map((c) => c.proId));
for (const pro of found) {
expect(shownIds.has(pro.proId)).toBe(true);
}
});
it('excludes the unverified, the away and the too-far', async () => {
const names = (await searchPros(db, { ...CENTRE, limit: 50 })).map((p) => p.name);
expect(names).not.toContain('Unverified Ulises');
expect(names).not.toContain('Away Arturo');
expect(names).not.toContain('Pablo Rivas'); // 22 km out, 5 km radius
});
it('matches a skill', async () => {
const names = (await searchPros(db, { ...CENTRE, q: 'underfloor', limit: 50 })).map(
(p) => p.name,
);
expect(names).toContain('Sergio Fabela');
expect(names).not.toContain('Martino Gómez'); // an electrician with no such skill
});
it('matches a trade name', async () => {
const results = await searchPros(db, { ...CENTRE, q: 'electrician', limit: 50 });
expect(results.length).toBeGreaterThan(0);
expect(results.every((p) => p.categories.includes('Electrician'))).toBe(true);
});
it('matches a pro by name', async () => {
const names = (await searchPros(db, { ...CENTRE, q: 'fabela', limit: 50 })).map((p) => p.name);
expect(names).toEqual(['Sergio Fabela']);
});
it('treats wildcards as literal characters', async () => {
// Without escaping, '%' matches everyone and the filter looks broken.
const results = await searchPros(db, { ...CENTRE, q: '%', limit: 50 });
expect(results).toHaveLength(0);
});
it('narrows to one trade', async () => {
const results = await searchPros(db, { ...CENTRE, categoryId: plumberId, limit: 50 });
expect(results.length).toBeGreaterThan(0);
expect(results.every((p) => p.categories.includes('Plumber'))).toBe(true);
});
it("honours the searcher's own distance limit", async () => {
const near = await searchPros(db, { ...CENTRE, maxDistanceM: 3_000, limit: 50 });
expect(near.every((p) => p.distanceM <= 3_000)).toBe(true);
expect(near.map((p) => p.name)).not.toContain('Mario Villanueva'); // 9.1 km out
});
it('sorts by distance, price and rating', async () => {
const nearest = await searchPros(db, { ...CENTRE, sort: 'nearest', limit: 50 });
for (let i = 1; i < nearest.length; i++) {
expect(nearest[i]!.distanceM).toBeGreaterThanOrEqual(nearest[i - 1]!.distanceM);
}
const cheapest = await searchPros(db, { ...CENTRE, sort: 'price', limit: 50 });
for (let i = 1; i < cheapest.length; i++) {
expect(cheapest[i]!.hourlyRateCents).toBeGreaterThanOrEqual(cheapest[i - 1]!.hourlyRateCents);
}
const rated = await searchPros(db, { ...CENTRE, sort: 'rating', limit: 50 });
const scored = rated.filter((p) => p.ratingAvg !== null);
for (let i = 1; i < scored.length; i++) {
expect(scored[i]!.ratingAvg!).toBeLessThanOrEqual(scored[i - 1]!.ratingAvg!);
}
// Unrated pros sort last rather than being read as zero.
const firstUnrated = rated.findIndex((p) => p.ratingAvg === null);
if (firstUnrated !== -1) {
expect(rated.slice(firstUnrated).every((p) => p.ratingAvg === null)).toBe(true);
}
});
it('excludes the unrated from a rating floor', async () => {
const results = await searchPros(db, { ...CENTRE, minRating: 4.5, limit: 50 });
expect(results.every((p) => p.ratingAvg !== null && p.ratingAvg >= 4.5)).toBe(true);
});
it('honours a price ceiling and the limit', async () => {
const cheap = await searchPros(db, { ...CENTRE, maxHourlyRateCents: 3_000, limit: 50 });
expect(cheap.every((p) => p.hourlyRateCents <= 3_000)).toBe(true);
const two = await searchPros(db, { ...CENTRE, limit: 2 });
expect(two).toHaveLength(2);
});
});