import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import { resetDb, closeDb } from "./setup"; import { setTransportForTests, mockTransport } from "../../src/lib/auth/email"; import { db } from "../../src/db/client"; import { users, userProfiles, listingMedia } from "../../src/db/schema"; import { createListing, updateListing, transition, attest, getListingFull, ConflictError } from "../../src/lib/listings/service"; import { openInquiry, getThread, reply, requestViewing, updateViewing } from "../../src/lib/inquiries/service"; import { searchDirect } from "../../src/lib/listings/public"; import { sanitizeBody } from "../../src/lib/inquiries/service"; async function mkUser(email: string) { const [u] = await db().insert(users).values({ email, emailVerifiedAt: new Date() }).returning(); await db().insert(userProfiles).values({ userId: u.id }); return u; } async function fakeMedia(listingId: string) { await db().insert(listingMedia).values({ listingId, storageKey: "2026-10/abcdefabcdefabcdefabcdef", status: "READY", scanStatus: "CLEAN", position: 0 }); } const base = { address: "123 rue Test", city: "Québec", lat: 46.81, lng: -71.21, bedrooms: 3, bathrooms: 1, areaSqft: 1200, yearBuilt: 1990, title: "Beau logement lumineux près du parc", description: "Grand logement rénové, très lumineux, près des services, du parc et des transports. Disponible rapidement, idéal pour une famille." }; describe("publications directes : un compte, plusieurs annonces, isolation", () => { beforeAll(() => setTransportForTests(mockTransport)); afterAll(closeDb); beforeEach(resetDb); it("publie un logement à louer et une maison à vendre depuis le même compte ; visibles dans la recherche", async () => { const u = await mkUser("owner@exemple.ca"); const rent = await createListing(u.id, { listingType: "RENT", propertyType: "appartement", ...base, priceAmount: 1450 }); const sale = await createListing(u.id, { listingType: "SALE", propertyType: "maison", ...base, title: "Maison familiale avec grand terrain boisé", priceAmount: 489000 }); const r0 = await transition(rent.id, u.id, [], "publish"); expect(r0.problems).toContain("Au moins une photo requise"); expect(r0.problems).toContain("Attestation du droit de publier manquante"); await fakeMedia(rent.id); await fakeMedia(sale.id); await attest(rent.id, u.id, []); await attest(sale.id, u.id, []); expect((await transition(rent.id, u.id, [], "publish")).listing.status).toBe("PUBLISHED"); expect((await transition(sale.id, u.id, [], "publish")).listing.status).toBe("PUBLISHED"); expect((await searchDirect({ market: "rent", city: "Québec" })).map((c) => c.uid)).toContain(`toveli:${rent.slug}`); expect((await searchDirect({ market: "sale", priceMax: 500000 })).map((c) => c.uid)).toContain(`toveli:${sale.slug}`); await transition(rent.id, u.id, [], "pause"); expect((await searchDirect({ market: "rent" })).length).toBe(0); await transition(rent.id, u.id, [], "resume"); await transition(rent.id, u.id, [], "mark-rented"); expect((await getListingFull(rent.id))!.status).toBe("RENTED"); }); it("un autre utilisateur ne peut ni modifier, ni publier, ni lire les messages", async () => { const a = await mkUser("a@exemple.ca"), b = await mkUser("b@exemple.ca"); const l = await createListing(a.id, { listingType: "RENT", propertyType: "condo", ...base, priceAmount: 1800 }); await expect(updateListing(l.id, b.id, [], { title: "pirate" })).rejects.toThrow(); await expect(transition(l.id, b.id, [], "publish")).rejects.toThrow(); await fakeMedia(l.id); await attest(l.id, a.id, []); await transition(l.id, a.id, [], "publish"); const inq = await openInquiry(l.id, b.id, "INFO", "Bonjour, est-ce encore disponible ? Mon courriel b@x.ca et mon numéro 418 555-0199."); const c = await mkUser("c@exemple.ca"); await expect(getThread(inq.id, c.id)).rejects.toThrow(); await expect(reply(inq.id, c.id, "intrus")).rejects.toThrow(); const t = await getThread(inq.id, a.id); expect(t.messages[0].body).toContain("[courriel masqué]"); expect(t.messages[0].body).toContain("[téléphone masqué]"); await expect(openInquiry(l.id, a.id, "INFO", "je m'écris à moi-même ?")).rejects.toThrow(); }); it("concurrence optimiste et spam → vérification humaine", async () => { const a = await mkUser("d@exemple.ca"); const l = await createListing(a.id, { listingType: "RENT", propertyType: "appartement", ...base, priceAmount: 900 }); await updateListing(l.id, a.id, [], { title: "Titre un", version: 1 }); await expect(updateListing(l.id, a.id, [], { title: "Titre périmé", version: 1 })).rejects.toThrow(ConflictError); await fakeMedia(l.id); await attest(l.id, a.id, []); await updateListing(l.id, a.id, [], { description: "Envoyez un dépôt par Western Union et les clés arrivent par la poste. Contact WhatsApp. Grand logement lumineux." }); expect((await transition(l.id, a.id, [], "publish")).listing.status).toBe("PENDING_REVIEW"); }); it("visites : créneaux, confirmation par le propriétaire seulement, conflit à 30 min", async () => { const a = await mkUser("e@exemple.ca"), b = await mkUser("f@exemple.ca"), c = await mkUser("g@exemple.ca"); const l = await createListing(a.id, { listingType: "SALE", propertyType: "maison", ...base, priceAmount: 400000 }); await fakeMedia(l.id); await attest(l.id, a.id, []); await transition(l.id, a.id, [], "publish"); const slot = new Date(Date.now() + 2 * 86400000).toISOString(); const v1 = await requestViewing(l.id, b.id, [slot]); const v2 = await requestViewing(l.id, c.id, [slot]); await expect(updateViewing(v1.id, b.id, "confirm", slot)).rejects.toThrow(); await updateViewing(v1.id, a.id, "confirm", slot); await expect(updateViewing(v2.id, a.id, "confirm", slot)).rejects.toThrow(/Conflit/); }); it("sanitize", () => { expect(sanitizeBody("voir https://evil.example/x et https://www.toveli.ca/annonce/1")).toBe("voir [lien retiré] et https://www.toveli.ca/annonce/1"); }); });