test: integration coverage for stores, services, MockAIService, and hooks

- Stores (29 tests): compareStore max-4 enforcement, toastStore unique IDs,
  shortlistStore dialog state, assistantStore context merge + clearConversation
- Services (37 tests): needService CRUD, propertyService dashboard summary
  active-status filter, matchService computeMatchesForNeed + getStrongMatches,
  futureSignalService summary math (distribution totals, highConfidence count)
- MockAIService (18 tests): follow-up question priority ordering, area ambiguity
  detection (ratio >8 and zero values), max-3 cap, trade-off risk logic
  (LOW/MEDIUM/HIGH thresholds), match explanation headline tiers, provenance shape
- Hooks (20 tests): useNeeds/useProperties/useMatches envelope unwrap, disabled-
  when-empty guards, useCreateNeed/useCreateProperty mutations, useNeed/useMatchDetail

All 257 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 16:55:02 +02:00
parent e1f4beb898
commit 58a0a05eff
12 changed files with 1051 additions and 0 deletions
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { futureSignalService } from '../futureSignalService'
describe('futureSignalService', () => {
describe('getAll', () => {
it('returns a ListResponse with signal data', async () => {
const result = await futureSignalService.getAll()
expect(Array.isArray(result.data)).toBe(true)
expect(result.data.length).toBeGreaterThan(0)
expect(result.meta.total).toBe(result.data.length)
})
it('each signal has required fields', async () => {
const { data } = await futureSignalService.getAll()
for (const s of data) {
expect(typeof s.id).toBe('string')
if (s.propertyId !== undefined) expect(typeof s.propertyId).toBe('string')
expect(typeof s.confidenceScore).toBe('number')
expect(s.confidenceScore).toBeGreaterThanOrEqual(0)
expect(s.confidenceScore).toBeLessThanOrEqual(1)
}
})
})
describe('getById', () => {
it('returns a signal for an existing id', async () => {
const { data: all } = await futureSignalService.getAll()
const id = all[0].id
const result = await futureSignalService.getById(id)
expect(result.data?.id).toBe(id)
})
it('returns null for an unknown id', async () => {
const result = await futureSignalService.getById('no-such-signal')
expect(result.data).toBeNull()
})
})
describe('getByProperty', () => {
it('returns only signals for the given propertyId', async () => {
const { data: all } = await futureSignalService.getAll()
const withPropertyId = all.find(s => s.propertyId)
if (!withPropertyId?.propertyId) return // no signals with propertyId in seed data
const result = await futureSignalService.getByProperty(withPropertyId.propertyId)
expect(result.data.every(s => s.propertyId === withPropertyId.propertyId)).toBe(true)
})
})
describe('getSignalSummary', () => {
it('returns totals and distribution', async () => {
const summary = await futureSignalService.getSignalSummary()
expect(typeof summary.total).toBe('number')
expect(typeof summary.highConfidence).toBe('number')
expect(typeof summary.restricted).toBe('number')
expect(typeof summary.needsReview).toBe('number')
expect(typeof summary.avgTimeHorizonMonths).toBe('number')
expect(typeof summary.timeHorizonDistribution.short).toBe('number')
expect(typeof summary.timeHorizonDistribution.medium).toBe('number')
expect(typeof summary.timeHorizonDistribution.long).toBe('number')
})
it('highConfidence count matches signals with score >= 0.75', async () => {
const { data: all } = await futureSignalService.getAll()
const expected = all.filter(s => s.confidenceScore >= 0.75).length
const summary = await futureSignalService.getSignalSummary()
expect(summary.highConfidence).toBe(expected)
})
it('needsReview count matches unverified signals', async () => {
const { data: all } = await futureSignalService.getAll()
const expected = all.filter(s => !s.isVerified).length
const summary = await futureSignalService.getSignalSummary()
expect(summary.needsReview).toBe(expected)
})
it('distribution totals add up to total signal count', async () => {
const summary = await futureSignalService.getSignalSummary()
const { short, medium, long } = summary.timeHorizonDistribution
expect(short + medium + long).toBe(summary.total)
})
})
})
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest'
import { matchService } from '../matchService'
describe('matchService', () => {
describe('getAll', () => {
it('returns a ListResponse with match data', async () => {
const result = await matchService.getAll()
expect(Array.isArray(result.data)).toBe(true)
expect(result.data.length).toBeGreaterThan(0)
expect(result.meta.total).toBe(result.data.length)
})
it('each match has required fields', async () => {
const { data } = await matchService.getAll()
for (const m of data) {
expect(typeof m.id).toBe('string')
expect(typeof m.needId).toBe('string')
expect(typeof m.propertyId).toBe('string')
expect(typeof m.matchScore).toBe('number')
expect(m.matchScore).toBeGreaterThanOrEqual(0)
expect(m.matchScore).toBeLessThanOrEqual(100)
}
})
})
describe('getById', () => {
it('returns a match for an existing id', async () => {
const { data: all } = await matchService.getAll()
const id = all[0].id
const result = await matchService.getById(id)
expect(result.data?.id).toBe(id)
})
it('returns null for an unknown id', async () => {
const result = await matchService.getById('no-such-match')
expect(result.data).toBeNull()
})
})
describe('getByNeed', () => {
it('returns only matches for the given needId', async () => {
const { data: all } = await matchService.getAll()
const needId = all[0].needId
const result = await matchService.getByNeed(needId)
expect(result.data.every(m => m.needId === needId)).toBe(true)
})
})
describe('getByProperty', () => {
it('returns only matches for the given propertyId', async () => {
const { data: all } = await matchService.getAll()
const propertyId = all[0].propertyId
const result = await matchService.getByProperty(propertyId)
expect(result.data.every(m => m.propertyId === propertyId)).toBe(true)
})
})
describe('getScoreBreakdown', () => {
it('returns a ScoreBreakdown for a valid match', async () => {
const { data: all } = await matchService.getAll()
const matchWithBreakdown = all.find(m => !!m.scoreBreakdown)
if (!matchWithBreakdown) return // seed data may not always have breakdowns
const result = await matchService.getScoreBreakdown(matchWithBreakdown.id)
expect(result.data).not.toBeNull()
})
it('returns null for an unknown match', async () => {
const result = await matchService.getScoreBreakdown('no-such-match')
expect(result.data).toBeNull()
})
})
describe('computeMatchesForNeed', () => {
it('returns computed matches for a valid needId', async () => {
const result = await matchService.computeMatchesForNeed('need-001')
expect(result.data.length).toBeGreaterThan(0)
expect(result.data.every(m => m.needId === 'need-001')).toBe(true)
})
it('returns empty array for unknown needId', async () => {
const result = await matchService.computeMatchesForNeed('no-such-need')
expect(result.data).toEqual([])
})
})
describe('getStrongMatches', () => {
it('returns only matches at or above the threshold', async () => {
const minScore = 70
const result = await matchService.getStrongMatches(minScore)
expect(result.every(m => m.matchScore >= minScore)).toBe(true)
})
it('returns at most 5 items', async () => {
const result = await matchService.getStrongMatches(0)
expect(result.length).toBeLessThanOrEqual(5)
})
it('each item has propertyTitle and topReason', async () => {
const result = await matchService.getStrongMatches(0)
for (const item of result) {
expect(typeof item.propertyTitle).toBe('string')
expect(typeof item.topReason).toBe('string')
expect(typeof item.matchScore).toBe('number')
}
})
})
})
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest'
import { needService } from '../needService'
describe('needService', () => {
describe('getAll', () => {
it('returns a ListResponse with data and meta', async () => {
const result = await needService.getAll()
expect(Array.isArray(result.data)).toBe(true)
expect(result.data.length).toBeGreaterThan(0)
expect(result.meta.total).toBe(result.data.length)
expect(result.meta.page).toBe(1)
expect(result.meta.hasMore).toBe(false)
})
it('each need has required fields', async () => {
const { data } = await needService.getAll()
for (const need of data) {
expect(typeof need.id).toBe('string')
expect(typeof need.companyName).toBe('string')
expect(typeof need.assetType).toBe('string')
expect(typeof need.requiredArea.min).toBe('number')
expect(typeof need.requiredArea.max).toBe('number')
}
})
})
describe('getById', () => {
it('returns a need for an existing id', async () => {
const { data: all } = await needService.getAll()
const id = all[0].id
const result = await needService.getById(id)
expect(result.data).not.toBeNull()
expect(result.data?.id).toBe(id)
})
it('returns null for a non-existent id', async () => {
const result = await needService.getById('nonexistent-id')
expect(result.data).toBeNull()
})
})
describe('create', () => {
it('creates a new need and returns it', async () => {
const { data: before } = await needService.getAll()
const input = {
companyName: 'Test GmbH',
assetType: 'OFFICE' as const,
requiredArea: { min: 200, max: 400 },
preferredLocations: ['Zürich'],
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' as const },
organizationId: 'org-test',
}
const result = await needService.create(input)
expect(result.data.companyName).toBe('Test GmbH')
expect(typeof result.data.id).toBe('string')
const { data: after } = await needService.getAll()
expect(after.length).toBe(before.length + 1)
})
})
describe('update', () => {
it('updates a need and reflects the change', async () => {
const { data: all } = await needService.getAll()
const target = all[0]
const result = await needService.update(target.id, { companyName: 'Updated GmbH' })
expect(result.data.id).toBe(target.id)
expect(result.data.companyName).toBe('Updated GmbH')
})
})
describe('remove', () => {
it('removes a need so it no longer appears in getAll', async () => {
// Create a temporary need to avoid disturbing shared seed data
const created = await needService.create({
companyName: 'Temp GmbH',
assetType: 'OFFICE' as const,
requiredArea: { min: 100, max: 200 },
preferredLocations: ['Bern'],
budgetRange: { maxPerSqm: 300, maxMonthlyTotal: 5000, currency: 'CHF' as const },
organizationId: 'org-test',
})
const id = created.data.id
await needService.remove(id)
const found = await needService.getById(id)
expect(found.data).toBeNull()
})
})
})
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import { propertyService } from '../propertyService'
describe('propertyService', () => {
describe('getAll', () => {
it('returns a ListResponse with data and meta', async () => {
const result = await propertyService.getAll()
expect(Array.isArray(result.data)).toBe(true)
expect(result.data.length).toBeGreaterThan(0)
expect(result.meta.total).toBe(result.data.length)
expect(result.meta.hasMore).toBe(false)
})
it('each property has required fields', async () => {
const { data } = await propertyService.getAll()
for (const p of data) {
expect(typeof p.id).toBe('string')
expect(typeof p.title).toBe('string')
expect(typeof p.areaSqm).toBe('number')
expect(typeof p.resultType).toBe('string')
expect(typeof p.location.city).toBe('string')
}
})
})
describe('getById', () => {
it('returns a property for an existing id', async () => {
const { data: all } = await propertyService.getAll()
const id = all[0].id
const result = await propertyService.getById(id)
expect(result.data?.id).toBe(id)
})
it('returns null for an unknown id', async () => {
const result = await propertyService.getById('no-such-property')
expect(result.data).toBeNull()
})
})
describe('getDashboardPropertiesSummary', () => {
it('returns total and active counts', async () => {
const summary = await propertyService.getDashboardPropertiesSummary()
expect(typeof summary.total).toBe('number')
expect(typeof summary.active).toBe('number')
expect(summary.active).toBeLessThanOrEqual(summary.total)
})
it('active count only includes AVAILABLE_NOW or AVAILABLE_SOON', async () => {
const { data: all } = await propertyService.getAll()
const expectedActive = all.filter(
p => p.availabilityStatus === 'AVAILABLE_NOW' || p.availabilityStatus === 'AVAILABLE_SOON',
).length
const summary = await propertyService.getDashboardPropertiesSummary()
expect(summary.active).toBe(expectedActive)
})
})
describe('create', () => {
it('creates a property and returns it', async () => {
const input = {
title: 'Test Lagerfläche',
assetType: 'LOGISTICS' as const,
resultType: 'VERIFIED_PORTFOLIO' as const,
location: { city: 'Basel', country: 'CH' as const },
areaSqm: 500,
rentPricePerSqm: 120,
availabilityStatus: 'AVAILABLE_NOW' as const,
organizationId: 'org-test',
}
const result = await propertyService.create(input)
expect(typeof result.data.id).toBe('string')
expect(result.data.title).toBe('Test Lagerfläche')
})
})
})
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { MockAIService } from '../mock/MockAIService'
import type { ParsedNeedCriteria } from '../../../domain/needBuilder'
import type { TradeOffInput } from '../IAIService'
// Fast-forward simulated delays so tests don't take seconds
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
async function run<T>(promise: Promise<T>): Promise<T> {
// Advance timers while waiting — handles setTimeout-based delays
const p = promise
vi.runAllTimers()
return p
}
// ── generateFollowUpQuestions ─────────────────────────────────────────────────
describe('MockAIService.generateFollowUpQuestions', () => {
it('asks about assetType when missing', async () => {
const criteria: ParsedNeedCriteria = {}
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
expect(result.data.some(q => q.targetField === 'assetType')).toBe(true)
})
it('asks about areaRange when missing', async () => {
const criteria: ParsedNeedCriteria = { assetType: 'OFFICE' }
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
expect(result.data.some(q => q.targetField === 'areaRange')).toBe(true)
})
it('asks area ambiguity question when max/min > 8', async () => {
const criteria: ParsedNeedCriteria = {
assetType: 'OFFICE',
areaRange: { min: 100, max: 1000 }, // ratio = 10 > 8
preferredLocations: ['Zürich'],
}
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
const areaQ = result.data.find(q => q.targetField === 'areaRange')
expect(areaQ).toBeDefined()
expect(areaQ?.importance).toBe('required')
expect(areaQ?.questionText).toContain('weit gefasst')
})
it('treats zero min or max as ambiguous', async () => {
const criteria: ParsedNeedCriteria = {
assetType: 'OFFICE',
areaRange: { min: 0, max: 500 },
preferredLocations: ['Zürich'],
}
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
expect(result.data.some(q => q.targetField === 'areaRange')).toBe(true)
})
it('does not ask about areaRange when ratio <= 8', async () => {
const criteria: ParsedNeedCriteria = {
assetType: 'OFFICE',
areaRange: { min: 200, max: 600 }, // ratio = 3
preferredLocations: ['Zürich'],
}
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
expect(result.data.some(q => q.targetField === 'areaRange')).toBe(false)
})
it('returns at most 3 questions', async () => {
const criteria: ParsedNeedCriteria = {} // everything missing
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
expect(result.data.length).toBeLessThanOrEqual(3)
})
it('returns 0 questions when all fields are present', async () => {
const criteria: ParsedNeedCriteria = {
assetType: 'OFFICE',
areaRange: { min: 300, max: 600 },
preferredLocations: ['Zürich'],
budgetRange: { maxPerSqm: 400 },
timing: { earliestMoveIn: '2025-09-01' },
mustHaveCriteria: ['ÖV-Anbindung'],
}
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
expect(result.data).toHaveLength(0)
})
it('priorities required questions first', async () => {
const criteria: ParsedNeedCriteria = { preferredLocations: [] } // missing assetType (required), budgetRange (recommended)
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
if (result.data.length >= 2) {
expect(result.data[0].importance).toBe('required')
}
})
it('question ids are unique', async () => {
const criteria: ParsedNeedCriteria = {}
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
const ids = result.data.map(q => q.id)
expect(new Set(ids).size).toBe(ids.length)
})
})
// ── provenance ────────────────────────────────────────────────────────────────
describe('MockAIService provenance', () => {
it('returns mock provenance on generateFollowUpQuestions', async () => {
const result = await run(MockAIService.generateFollowUpQuestions({}))
expect(result.provenance.provider).toBe('mock')
expect(result.provenance.source).toBe('mock')
expect(result.provenance.fallbackUsed).toBe(false)
expect(result.provenance.validationPassed).toBe(true)
expect(typeof result.provenance.traceId).toBe('string')
expect(typeof result.provenance.generatedAt).toBe('string')
})
})
// ── summarizeTradeOffs ────────────────────────────────────────────────────────
describe('MockAIService.summarizeTradeOffs', () => {
it('returns LOW risk when no HIGH severity trade-offs', async () => {
const tradeoffs: TradeOffInput[] = [
{ concern: 'Lage', severity: 'LOW', mitigation: 'Umgebung gut erschlossen' },
]
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
expect(result.data.overallRisk).toBe('LOW')
})
it('returns MEDIUM risk when exactly 1 HIGH severity trade-off', async () => {
const tradeoffs: TradeOffInput[] = [
{ concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
]
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
expect(result.data.overallRisk).toBe('MEDIUM')
})
it('returns HIGH risk when 2+ HIGH severity trade-offs', async () => {
const tradeoffs: TradeOffInput[] = [
{ concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
{ concern: 'Budget weit überschritten', severity: 'HIGH', mitigation: '' },
]
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
expect(result.data.overallRisk).toBe('HIGH')
})
it('returns zero-trade-off headline when list is empty', async () => {
const result = await run(MockAIService.summarizeTradeOffs([]))
expect(result.data.headline).toContain('Keine')
expect(result.data.items).toHaveLength(0)
})
})
// ── generateMatchExplanation ──────────────────────────────────────────────────
describe('MockAIService.generateMatchExplanation', () => {
const base = {
propertyId: 'prop-1',
propertyTitle: 'Bürofläche Zollstrasse',
propertyCity: 'Zürich',
positiveFactors: [{ criterion: 'Lage', explanation: 'Zentrale Lage', weight: 0.3 }],
negativeFactors: [{ criterion: 'Preis', explanation: 'Etwas über Budget', weight: 0.2 }],
}
it('generates STARK headline for score >= 78', async () => {
const result = await run(MockAIService.generateMatchExplanation({ ...base, matchScore: 85 }))
expect(result.data.headline.toLowerCase()).toContain('stark')
})
it('generates MEDIUM headline for score 5277', async () => {
const result = await run(MockAIService.generateMatchExplanation({ ...base, matchScore: 65 }))
expect(result.data.headline.toLowerCase()).toContain('gut')
})
it('generates WEAK headline for score < 52', async () => {
const result = await run(MockAIService.generateMatchExplanation({ ...base, matchScore: 40 }))
expect(result.data.headline.toLowerCase()).toContain('schwach')
})
it('includes property title in summary', async () => {
const result = await run(MockAIService.generateMatchExplanation({ ...base, matchScore: 80 }))
expect(result.data.summary).toContain('Bürofläche Zollstrasse')
})
})