/** * E2E-tester för Bounty-flödet (Skvadron G) * * DoD: * - Ett komplett e2e-lyft: källbelagd → fältverifierad * - Feedback→bounty→foto→fältverifierad sluter loopen * - Kunddrivna kön mätbar */ const request = require('supertest'); const app = require('../src/app'); const Bounty = require('../src/models/bounty'); const BountyEngine = require('../src/services/bountyEngine'); describe('Skvadron G · QUIXZOOM fältflöde', () => { describe('Bounty-motor', () => { test('ladda bounty-data från JSON', () => { const bountiesData = require('../../../10-data/lvx-zoomer-bounties-vag1.json'); expect(bountiesData.bounties).toHaveLength(16); expect(bountiesData.bounties[0].bounty_id).toBe('BNTY-0001'); expect(bountiesData.bounties[0].poang).toBe(20); }); test('ladda produktmodeller från JSON', () => { const modelsData = require('../../../10-data/lvx-produktmodeller-auto.json'); expect(modelsData.poster).toBeDefined(); expect(modelsData.poster.length).toBeGreaterThan(0); }); test('skapa bounty via API', async () => { const res = await request(app) .post('/v0/bounties') .send({ titel: 'Test-bounty: Armaturens typskylt', mal_lvx: 'LVX-TRP-0102', foton: ['etikett läsbar', 'armaturens sida'], ocr_falt: ['tillverkare', 'modell'], poang: 20, prioritet: 'hog', position: { lat: 59.3293, lon: 18.0686, accuracy: 5 } }); expect(res.status).toBe(201); expect(res.body.success).toBe(true); expect(res.body.bounty.bounty_id).toBeDefined(); expect(res.body.bounty.poang).toBe(20); }); test('hämta bounty-status', async () => { const createRes = await request(app) .post('/v0/bounties') .send({ titel: 'Test-bounty', mal_lvx: 'LVX-VAT-0101', position: { lat: 59.3293, lon: 18.0686 } }); const bountyId = createRes.body.bounty.bounty_id; const res = await request(app) .get(`/v0/bounties/${bountyId}`); expect(res.status).toBe(200); expect(res.body.bounty.bounty_id).toBe(bountyId); expect(res.body.bounty.status).toBe('oppen'); }); test('hitta bounties nära position', async () => { // Skapa bounty på känd position await request(app) .post('/v0/bounties') .send({ titel: 'Närbounty', mal_lvx: 'LVX-TRP-0101', position: { lat: 59.3293, lon: 18.0686 } }); const res = await request(app) .get('/v0/bounties/nearby?lat=59.3293&lon=18.0686&radius=1'); expect(res.status).toBe(200); expect(res.body.bounties.length).toBeGreaterThan(0); expect(res.body.bounties[0].distance_km).toBeDefined(); }); }); describe('Fältverifieringsflöde', () => { test('komplett e2e-lyft: källbelagd → fältverifierad', async () => { // 1. Skapa bounty const bountyRes = await request(app) .post('/v0/bounties') .send({ titel: 'Hydrantens gjutmärkning', mal_lvx: 'LVX-VAT-0103', foton: ['helbild', 'närbild gjuten text'], ocr_falt: ['tillverkare', 'ventilstorlek', 'år', 'öppningsriktning'], poang: 15, position: { lat: 32.78, lon: -96.80 }, kund_ref: 'kund-123' }); const bountyId = bountyRes.body.bounty.bounty_id; expect(bountyRes.status).toBe(201); // 2. Zoomer skickar foto const submitRes = await request(app) .post(`/v0/bounties/${bountyId}/submit`) .send({ zoomer_id: 'zoomer-001', position: { lat: 32.78, lon: -96.80 }, timestamp: new Date().toISOString(), accuracy: 3, image_base64: Buffer.from('test-image-data-hydrant-1998').toString('base64') }); expect(submitRes.status).toBe(201); expect(submitRes.body.submission.status).toBe('inskickad'); expect(submitRes.body.submission.poang).toBeGreaterThan(0); const submissionId = submitRes.body.submission.id; // 3. Godkänn submission const approveRes = await request(app) .post(`/v0/bounties/${bountyId}/approve`) .send({ submission_id: submissionId, granskningsnotering: 'Godkänd — tydligt gjutmärke' }); expect(approveRes.status).toBe(200); expect(approveRes.body.success).toBe(true); expect(approveRes.body.individ).toBeDefined(); expect(approveRes.body.individ.verifieringsniva).toBe('faltverifierad'); expect(approveRes.body.message).toContain('fältverifierat'); // 4. Verifiera att notifikation skapats const notifications = Bounty.findNotificationsByCustomer('kund-123'); expect(notifications.length).toBeGreaterThan(0); expect(notifications[0].meddelande).toContain('fältverifierat'); }); test('dublettkontroll stoppar återinlämning', async () => { const bountyRes = await request(app) .post('/v0/bounties') .send({ titel: 'Dublett-test', mal_lvx: 'LVX-TRP-0101', position: { lat: 59.3293, lon: 18.0686 } }); const bountyId = bountyRes.body.bounty.bounty_id; const imageBase64 = Buffer.from('samma-bild').toString('base64'); // Första submission const submit1 = await request(app) .post(`/v0/bounties/${bountyId}/submit`) .send({ zoomer_id: 'zoomer-001', position: { lat: 59.3293, lon: 18.0686 }, timestamp: new Date().toISOString(), image_base64: imageBase64 }); expect(submit1.status).toBe(201); // Andra submission med samma bild const submit2 = await request(app) .post(`/v0/bounties/${bountyId}/submit`) .send({ zoomer_id: 'zoomer-002', position: { lat: 59.3293, lon: 18.0686 }, timestamp: new Date().toISOString(), image_base64: imageBase64 }); expect(submit2.status).toBe(400); expect(submit2.body.error).toContain('Dublett'); }); test('poängsystem: först-på-individ ×2', async () => { const bountyRes = await request(app) .post('/v0/bounties') .send({ titel: 'Poäng-test', mal_lvx: 'LVX-ELN-0102', poang: 20, position: { lat: 59.3293, lon: 18.0686 } }); const bountyId = bountyRes.body.bounty.bounty_id; // Första submission på ny position const submitRes = await request(app) .post(`/v0/bounties/${bountyId}/submit`) .send({ zoomer_id: 'zoomer-001', position: { lat: 59.3293, lon: 18.0686 }, timestamp: new Date().toISOString(), accuracy: 2, image_base64: Buffer.from('forsta-bild').toString('base64') }); expect(submitRes.status).toBe(201); expect(submitRes.body.submission.is_first_on_individual).toBe(true); expect(submitRes.body.submission.poang_breakdown.firstOnIndividual).toBeDefined(); expect(submitRes.body.submission.poang).toBeGreaterThan(20); // ×2 = 40+ }); test('integritetsregler: auto-blurr', async () => { const privacy = require('../src/utils/safety').checkPrivacy('ABC 123 DEF 456'); expect(privacy.safe).toBe(false); expect(privacy.violations.length).toBeGreaterThan(0); expect(privacy.blurred).toContain('[BLURRAT]'); }); test('säkerhetsregler: auto-blurr, inga registreringsskyltar', () => { const { SAFETY_RULES } = require('../src/utils/safety'); expect(SAFETY_RULES.autoBlurPatterns.length).toBeGreaterThan(0); expect(SAFETY_RULES.zoomerRules).toContain('Inga personer, ansikten eller läsbara registreringsskyltar i bild'); }); }); describe('Feedback→bounty loop', () => { test('feedback skapar bounty automatiskt', async () => { const res = await request(app) .post('/v0/feedback') .send({ kund_ref: 'kund-test-456', position: { lat: 51.51, lon: -0.13 }, kommentar: 'Okänt grått gatuskåp utan etikett', ocr_extraherat: [] }); expect(res.status).toBe(201); expect(res.body.success).toBe(true); expect(res.body.bounty).toBeDefined(); expect(res.body.bounty.bounty_id).toBeDefined(); }); test('kunddrivna kön är mätbar', async () => { // Skapa flera feedback for (let i = 0; i < 3; i++) { await request(app) .post('/v0/feedback') .send({ kund_ref: `kund-${i}`, position: { lat: 59.32 + i * 0.01, lon: 18.06 + i * 0.01 }, kommentar: `Feedback ${i}` }); } const statsRes = await request(app) .get('/v0/stats/queue'); expect(statsRes.status).toBe(200); expect(statsRes.body.totalt_i_flode).toBeGreaterThanOrEqual(3); }); }); describe('LVX-I individregister', () => { test('individ skapas vid godkännande', () => { const individ = Bounty.createIndivid({ lvx_id: 'LVX-VAT-0103', position_lat: 32.78, position_lon: -96.80, verifieringsniva: 'faltverifierad', faltdata: { tillverkare: 'American-Darling', ar: '1998' }, bilder: ['hash123'] }); expect(individ.individ_id).toBeDefined(); expect(individ.individ_id.startsWith('LVX-I-')).toBe(true); }); test('dublett på position uppdaterar befintlig individ', () => { // Skapa första individ const individ1 = Bounty.createIndivid({ lvx_id: 'LVX-TRP-0102', position_lat: 59.3293, position_lon: 18.0686, faltdata: { tillverkare: 'GE' }, bilder: ['hash1'] }); // Sök nära position const found = Bounty.findIndividByPosition(59.3293, 18.0686, 10); expect(found).not.toBeNull(); }); }); describe('Zoomer-profil och rykte', () => { test('zoomer-profil skapas automatiskt', () => { const profile = Bounty.getOrCreateZoomer('zoomer-test-001', 'Test Zoomer'); expect(profile.zoomer_id).toBe('zoomer-test-001'); expect(profile.rykte).toBe(0.5); expect(profile.niva).toBe('ny'); }); test('rykte uppdateras vid godkännande/avvisning', () => { Bounty.getOrCreateZoomer('zoomer-test-002'); // Godkänn Bounty.updateZoomerStats('zoomer-test-002', 20, true); const profile = Bounty.getOrCreateZoomer('zoomer-test-002'); expect(profile.godkanda_submissioner).toBe(1); expect(profile.rykte).toBe(1.0); }); }); });