Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next"
"clean": "rm -rf .next",
"test": "vitest run"
},
"dependencies": {
"@solarproof/stellar": "workspace:*",
Expand All @@ -26,10 +27,12 @@
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitest/coverage-v8": "3.2.4",
"eslint": "^9.17.0",
"eslint-config-next": "15.1.3",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"vitest": "3.2.4"
}
}
220 changes: 220 additions & 0 deletions apps/web/src/__tests__/readings.route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'

// ── Mocks ──────────────────────────────────────────────────────────────────

const mockMeterSingle = vi.fn()
const mockInsertSingle = vi.fn()
const mockInsertSelect = vi.fn(() => ({ single: mockInsertSingle }))
const mockInsert = vi.fn(() => ({ select: mockInsertSelect }))
const mockUpdateEq = vi.fn()
const mockUpdate = vi.fn(() => ({ eq: mockUpdateEq }))

// Supabase query builder for meters: .select().eq().eq().single()
function meterQueryBuilder() {
const builder: Record<string, unknown> = {}
builder.eq = () => builder
builder.single = mockMeterSingle
return builder
}

vi.mock('@/lib/supabase', () => ({
createServiceClient: () => ({
from: (table: string) => {
if (table === 'meters') return { select: () => meterQueryBuilder() }
if (table === 'readings') return { insert: mockInsert, update: mockUpdate }
if (table === 'certificates') return { insert: vi.fn() }
return {}
},
}),
}))

vi.mock('@noble/ed25519', () => ({ verify: vi.fn() }))
vi.mock('@/lib/stellar', () => ({
anchorReading: vi.fn(),
mintCertificates: vi.fn(),
}))
vi.mock('@/lib/crypto', () => ({
computeReadingHash: vi.fn(() => Buffer.alloc(32)),
}))
vi.mock('@solarproof/stellar', () => ({ kwhToStroops: vi.fn(() => BigInt(12500000)) }))

import { verify } from '@noble/ed25519'
import { anchorReading, mintCertificates } from '@/lib/stellar'
import { POST } from '@/app/api/readings/route'

// ── Helpers ────────────────────────────────────────────────────────────────

const VALID_METER_ID = '123e4567-e89b-12d3-a456-426614174000'
const VALID_SIG = 'a'.repeat(128)

function makeReq(body: unknown) {
return new NextRequest('http://localhost/api/readings', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}

function validPayload(overrides: Record<string, unknown> = {}) {
return {
meter_id: VALID_METER_ID,
kwh: 12.5,
timestamp: 1700000000,
signature_hex: VALID_SIG,
...overrides,
}
}

// ── Tests ──────────────────────────────────────────────────────────────────

describe('POST /api/readings', () => {
beforeEach(() => {
vi.clearAllMocks()
})

// --- Invalid payload / missing fields ---

it('returns 400 for non-JSON body', async () => {
const req = new NextRequest('http://localhost/api/readings', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: 'not-json',
})
const res = await POST(req)
expect(res.status).toBe(400)
})

it('returns 400 when body is missing required fields', async () => {
const res = await POST(makeReq({}))
expect(res.status).toBe(400)
})

it('returns 400 for invalid uuid meter_id', async () => {
const res = await POST(makeReq(validPayload({ meter_id: 'not-a-uuid' })))
expect(res.status).toBe(400)
})

it('returns 400 for non-positive kwh', async () => {
const res = await POST(makeReq(validPayload({ kwh: -1 })))
expect(res.status).toBe(400)
})

it('returns 400 for zero kwh', async () => {
const res = await POST(makeReq(validPayload({ kwh: 0 })))
expect(res.status).toBe(400)
})

it('returns 400 for non-integer timestamp', async () => {
const res = await POST(makeReq(validPayload({ timestamp: 1700000000.5 })))
expect(res.status).toBe(400)
})

it('returns 400 for signature_hex with wrong length', async () => {
const res = await POST(makeReq(validPayload({ signature_hex: 'ab' })))
expect(res.status).toBe(400)
})

// --- Meter not found ---

it('returns 404 when meter does not exist', async () => {
mockMeterSingle.mockResolvedValue({ data: null })
vi.mocked(verify).mockResolvedValue(true)

const res = await POST(makeReq(validPayload()))
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error).toMatch(/meter not found/i)
})

// --- Invalid signature ---

it('returns 401 for invalid Ed25519 signature', async () => {
mockMeterSingle.mockResolvedValue({
data: { id: VALID_METER_ID, pubkey_hex: 'bb'.repeat(32), cooperative_id: 'coop1', cooperatives: { admin_address: 'GADDR' } },
})
vi.mocked(verify).mockResolvedValue(false)

const res = await POST(makeReq(validPayload()))
expect(res.status).toBe(401)
const json = await res.json()
expect(json.error).toMatch(/invalid meter signature/i)
})

it('returns 401 when signature verification throws', async () => {
mockMeterSingle.mockResolvedValue({
data: { id: VALID_METER_ID, pubkey_hex: 'bb'.repeat(32), cooperative_id: 'coop1', cooperatives: { admin_address: 'GADDR' } },
})
vi.mocked(verify).mockRejectedValue(new Error('bad key'))

const res = await POST(makeReq(validPayload()))
expect(res.status).toBe(401)
})

// --- DB save failure ---

it('returns 500 when DB insert fails', async () => {
mockMeterSingle.mockResolvedValue({
data: { id: VALID_METER_ID, pubkey_hex: 'bb'.repeat(32), cooperative_id: 'coop1', cooperatives: { admin_address: 'GADDR' } },
})
vi.mocked(verify).mockResolvedValue(true)
mockInsertSingle.mockResolvedValue({ data: null, error: new Error('DB error') })

const res = await POST(makeReq(validPayload()))
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toMatch(/failed to save reading/i)
})

// --- Anchor failure ---

it('returns 500 when on-chain anchor fails', async () => {
mockMeterSingle.mockResolvedValue({
data: { id: VALID_METER_ID, pubkey_hex: 'bb'.repeat(32), cooperative_id: 'coop1', cooperatives: { admin_address: 'GADDR' } },
})
vi.mocked(verify).mockResolvedValue(true)
mockInsertSingle.mockResolvedValue({ data: { id: 'read1' }, error: null })
mockUpdateEq.mockResolvedValue({})
vi.mocked(anchorReading).mockRejectedValue(new Error('Stellar anchor failed'))

const res = await POST(makeReq(validPayload()))
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toMatch(/stellar anchor failed/i)
expect(json.reading_id).toBe('read1')
})

// --- Mint failure ---

it('returns 500 when certificate mint fails', async () => {
mockMeterSingle.mockResolvedValue({
data: { id: VALID_METER_ID, pubkey_hex: 'bb'.repeat(32), cooperative_id: 'coop1', cooperatives: { admin_address: 'GADDR' } },
})
vi.mocked(verify).mockResolvedValue(true)
mockInsertSingle.mockResolvedValue({ data: { id: 'read1' }, error: null })
mockUpdateEq.mockResolvedValue({})
vi.mocked(anchorReading).mockResolvedValue('anchor-tx-abc')
vi.mocked(mintCertificates).mockRejectedValue(new Error('Mint failed'))

const res = await POST(makeReq(validPayload()))
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toMatch(/mint failed/i)
expect(json.anchor_tx_hash).toBe('anchor-tx-abc')
})

it('returns 500 when cooperative has no admin address', async () => {
mockMeterSingle.mockResolvedValue({
data: { id: VALID_METER_ID, pubkey_hex: 'bb'.repeat(32), cooperative_id: 'coop1', cooperatives: null },
})
vi.mocked(verify).mockResolvedValue(true)
mockInsertSingle.mockResolvedValue({ data: { id: 'read1' }, error: null })
mockUpdateEq.mockResolvedValue({})
vi.mocked(anchorReading).mockResolvedValue('anchor-tx-abc')

const res = await POST(makeReq(validPayload()))
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toMatch(/no cooperative admin address/i)
})
})
122 changes: 122 additions & 0 deletions apps/web/src/__tests__/verify.route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'

// ── Mocks ──────────────────────────────────────────────────────────────────

const mockCertSingle = vi.fn()
const mockReadingSingle = vi.fn()

vi.mock('@/lib/supabase', () => ({
createServiceClient: () => ({
from: (table: string) => {
if (table === 'certificates') {
return {
select: () => ({
or: () => ({ single: mockCertSingle }),
}),
}
}
if (table === 'readings') {
return {
select: () => ({
eq: () => ({ single: mockReadingSingle }),
}),
}
}
return {}
},
}),
}))

import { GET } from '@/app/api/verify/route'

// ── Helpers ────────────────────────────────────────────────────────────────

function makeReq(id?: string) {
const url = id
? `http://localhost/api/verify?id=${encodeURIComponent(id)}`
: 'http://localhost/api/verify'
return new NextRequest(url)
}

const CERT = {
id: 'cert-001',
reading_id: 'read-001',
kwh: 12.5,
issued_at: '2024-01-01T00:00:00Z',
retired: false,
retired_at: null,
retired_by: null,
reading_hash: 'deadbeef',
anchor_tx_hash: 'anchor-tx',
mint_tx_hash: 'mint-tx',
}

const READING = {
id: 'read-001',
meter_id: 'meter-001',
reading_hash: 'deadbeef',
signature_hex: 'a'.repeat(128),
kwh: 12.5,
timestamp: '2024-01-01T00:00:00Z',
}

// ── Tests ──────────────────────────────────────────────────────────────────

describe('GET /api/verify', () => {
beforeEach(() => vi.clearAllMocks())

it('returns 400 when id param is missing', async () => {
const res = await GET(makeReq())
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error).toMatch(/id parameter required/i)
})

it('returns 400 when id param is empty string', async () => {
const res = await GET(makeReq(''))
expect(res.status).toBe(400)
})

it('returns 404 when certificate is not found', async () => {
mockCertSingle.mockResolvedValue({ data: null })

const res = await GET(makeReq('unknown-id'))
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error).toMatch(/certificate not found/i)
})

it('returns 200 with full chain when certificate exists', async () => {
mockCertSingle.mockResolvedValue({ data: CERT })
mockReadingSingle.mockResolvedValue({ data: READING })

const res = await GET(makeReq('cert-001'))
expect(res.status).toBe(200)
const json = await res.json()
expect(json.certificate.id).toBe('cert-001')
expect(json.on_chain.anchor_tx).toBe('anchor-tx')
expect(json.meter_proof).not.toBeNull()
expect(json.meter_proof.meter_id).toBe('meter-001')
})

it('returns 200 with null meter_proof when reading is missing', async () => {
mockCertSingle.mockResolvedValue({ data: CERT })
mockReadingSingle.mockResolvedValue({ data: null })

const res = await GET(makeReq('cert-001'))
expect(res.status).toBe(200)
const json = await res.json()
expect(json.meter_proof).toBeNull()
})

it('returns Stellar explorer URLs in on_chain block', async () => {
mockCertSingle.mockResolvedValue({ data: CERT })
mockReadingSingle.mockResolvedValue({ data: null })

const res = await GET(makeReq('cert-001'))
const json = await res.json()
expect(json.on_chain.anchor_explorer).toContain('anchor-tx')
expect(json.on_chain.mint_explorer).toContain('mint-tx')
})
})
15 changes: 15 additions & 0 deletions apps/web/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
import path from 'path'

export default defineConfig({
test: {
environment: 'node',
globals: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@solarproof/stellar': path.resolve(__dirname, '../../packages/stellar/src/index.ts'),
},
},
})
Loading