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>
150 lines
5.9 KiB
TypeScript
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);
|
|
});
|
|
});
|