⋯ 1 line Show the 1 line above this hunk @@ -2,13 +2,13 @@ import { describe, expect, it } from 'bun:test' 2 2 import { Database } from 'bun:sqlite' 3 3 import { Compiler } from '../src/compiler' 4 4 import { elementHtml , tagFor } from '../src/elements' 5 - import { createHandlers , NotFound , type ReportStore } from '../src/http/handlers' 6 - import { reportRoutes } from '../src/http/routes' 5 + import { createHandlers , NotFound , ReadOnly , readQuery , type ReportStore } from '../src/http/handlers' 6 + import { isNotFound , reportRoutes } from '../src/http/routes' 7 7 import { Registry } from '../src/semantic' 8 8 import { Runner } from '../src/runner' 9 - import type { RenderedBlock } from '../src /types ' 9 + import type { Handlers , RenderedBlock } from '../src' 10 10 11 - function harness ( ) { 11 + function harness ( overrides : Partial < ReportStore > = { } ) { 12 12 const sqlite = new Database ( ':memory:' ) 13 13 sqlite . run ( `CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER, created_at TEXT)` ) 14 14 sqlite . run ( `INSERT INTO orders (total, created_at) VALUES (10000, '2026-08-03T10:00:00Z'), (5000, '2026-08-04T10:00:00Z')` ) ⋯ 18 lines Show the 18 lines above this hunk @@ -33,12 +33,19 @@ function harness() { 33 33 { id : 10 , kind : 'big_number' , title : 'Revenue' , x : 0 , y : 0 , w : 4 , h : 4 , query : { model : 'order' , measure : 'revenue' } } , 34 34 ] , 35 35 saveLayout : async ( _id , blocks ) => { saved . push ( ... blocks ) } , 36 + ... overrides , 36 37 } 37 38 38 39 return { handlers : createHandlers ( store , runner , registry ) , saved } 39 40 } 40 41 41 42 describe ( 'handlers' , ( ) => { 43 + it ( 'lists the reports exposed by the store' , async ( ) => { 44 + expect ( ( await harness ( ) . handlers . index ( ) ) . reports ) . toEqual ( [ 45 + { id : 1 , name : 'Commerce' , slug : 'commerce' , status : 'published' } , 46 + ] ) 47 + } ) 48 + 42 49 it ( 'renders a report' , async ( ) => { 43 50 const { blocks } = await harness ( ) . handlers . show ( 'commerce' ) 44 51 ⋯ 33 lines Show the 33 lines above this hunk @@ -78,9 +85,89 @@ describe('handlers', () => { 78 85 expect ( schema . models [ 0 ] . measures ) . toEqual ( [ 'revenue' ] ) 79 86 expect ( schema . grains ) . toContain ( 'day' ) 80 87 } ) 88 + 89 + it ( 'keeps code-defined reports read-only' , async ( ) => { 90 + const { handlers } = harness ( ) 91 + 92 + expect ( handlers . editable ) . toBe ( false ) 93 + await expect ( handlers . addBlock ( 'commerce' , 'bar' ) ) . rejects . toThrow ( ReadOnly ) 94 + await expect ( handlers . saveBlock ( 'commerce' , 10 , { title : 'Changed' } ) ) . rejects . toThrow ( ReadOnly ) 95 + await expect ( handlers . removeBlock ( 'commerce' , 10 ) ) . rejects . toThrow ( ReadOnly ) 96 + await expect ( handlers . publish ( 'commerce' ) ) . rejects . toThrow ( ReadOnly ) 97 + } ) 98 + 99 + it ( 'does not advertise an incomplete editor as writable' , async ( ) => { 100 + const { handlers } = harness ( { 101 + addBlock : async ( ) => ( { id : 11 , kind : 'bar' } ) , 102 + saveBlock : async ( ) => { } , 103 + removeBlock : async ( ) => { } , 104 + } ) 105 + 106 + expect ( handlers . editable ) . toBe ( false ) 107 + await expect ( handlers . publish ( 'commerce' ) ) . rejects . toThrow ( ReadOnly ) 108 + } ) 109 + 110 + it ( 'writes only known kinds, owned blocks, and editable fields' , async ( ) => { 111 + const calls : Array < [ string , ... unknown [ ] ] > = [ ] 112 + const { handlers } = harness ( { 113 + addBlock : async ( reportId , kind ) => { 114 + calls . push ( [ 'add' , reportId , kind ] ) 115 + return { id : 11 , kind : kind as any } 116 + } , 117 + saveBlock : async ( reportId , blockId , patch ) => { calls . push ( [ 'save' , reportId , blockId , patch ] ) } , 118 + removeBlock : async ( reportId , blockId ) => { calls . push ( [ 'remove' , reportId , blockId ] ) } , 119 + publish : async reportId => { calls . push ( [ 'publish' , reportId ] ) } , 120 + } ) 121 + 122 + expect ( handlers . editable ) . toBe ( true ) 123 + await expect ( handlers . addBlock ( 'commerce' , 'script' ) ) . rejects . toThrow ( NotFound ) 124 + expect ( ( await handlers . addBlock ( 'commerce' , 'bar' ) ) . block . id ) . toBe ( 11 ) 125 + 126 + await handlers . saveBlock ( 'commerce' , 10 , { 127 + id : 999 , 128 + kind : 'note' , 129 + reportId : 2 , 130 + title : 'Net revenue' , 131 + body : 'After refunds' , 132 + query : { model : 'order' , measure : 'revenue' , limit : '25' } , 133 + } ) 134 + await expect ( handlers . saveBlock ( 'commerce' , 999 , { title : 'Foreign' } ) ) . rejects . toThrow ( NotFound ) 135 + await handlers . removeBlock ( 'commerce' , 10 ) 136 + expect ( await handlers . publish ( 'commerce' ) ) . toEqual ( { published : true } ) 137 + 138 + expect ( calls ) . toEqual ( [ 139 + [ 'add' , 1 , 'bar' ] , 140 + [ 'save' , 1 , 10 , { 141 + title : 'Net revenue' , 142 + body : 'After refunds' , 143 + query : { model : 'order' , measure : 'revenue' , dimension : undefined , time : undefined , grain : undefined , from : undefined , to : undefined , filters : undefined , limit : 25 } , 144 + } ] , 145 + [ 'remove' , 1 , 10 ] , 146 + [ 'publish' , 1 ] , 147 + ] ) 148 + } ) 149 + 150 + it ( 'normalizes builder queries before they reach storage' , ( ) => { 151 + expect ( readQuery ( { model : 7 , measure : null , from : 20260801 , to : '' , limit : '12' } ) ) . toEqual ( { 152 + model : '7' , 153 + measure : '' , 154 + dimension : undefined , 155 + time : undefined , 156 + grain : undefined , 157 + from : '20260801' , 158 + to : undefined , 159 + filters : undefined , 160 + limit : 12 , 161 + } ) 162 + } ) 81 163 } ) 82 164 83 165 describe ( 'routes' , ( ) => { 166 + it ( 'identifies only package not-found errors' , ( ) => { 167 + expect ( isNotFound ( new NotFound ( 'missing' ) ) ) . toBe ( true ) 168 + expect ( isNotFound ( new Error ( 'missing' ) ) ) . toBe ( false ) 169 + } ) 170 + 84 171 it ( 'puts the fixed paths before the slug' , ( ) => { 85 172 86 173 ⋯ 11 lines Show the 11 lines above this hunk @@ -98,6 +185,47 @@ describe('routes', () => { 98 185 expect ( route . name . startsWith ( 'reportshq.' ) ) . toBe ( true ) 99 186 } 100 187 } ) 188 + 189 + it ( 'forwards route parameters and bodies to the handlers' , async ( ) => { 190 + const calls : Array < [ string , ... unknown [ ] ] > = [ ] 191 + const handlers : Handlers = { 192 + editable : true , 193 + index : async ( ) => { calls . push ( [ 'index' ] ) ; return { reports : [ ] } } , 194 + schema : ( ) => { calls . push ( [ 'schema' ] ) ; return { models : [ ] , grains : [ ] } } , 195 + show : async ( slug , published ) => { calls . push ( [ 'show' , slug , published ] ) ; return { report : { } , blocks : [ ] } } , 196 + download : async ( slug , format ) => { calls . push ( [ 'download' , slug , format ] ) ; return { body : '' , headers : { } } } , 197 + saveLayout : async ( slug , blocks ) => { calls . push ( [ 'layout' , slug , blocks ] ) ; return { blocks : [ ] } } , 198 + addBlock : async ( slug , kind ) => { calls . push ( [ 'add' , slug , kind ] ) ; return { block : { kind : 'note' } } } , 199 + saveBlock : async ( slug , id , body ) => { calls . push ( [ 'save' , slug , id , body ] ) ; return { saved : true } } , 200 + removeBlock : async ( slug , id ) => { calls . push ( [ 'remove' , slug , id ] ) ; return { removed : true } } , 201 + publish : async ( slug ) => { calls . push ( [ 'publish' , slug ] ) ; return { published : true } } , 202 + } 203 + const routes = new Map ( reportRoutes ( handlers ) . map ( route => [ route . name , route ] ) ) 204 + 205 + await routes . get ( 'reportshq.index' ) ! . handle ( { } ) 206 + await routes . get ( 'reportshq.schema' ) ! . handle ( { } ) 207 + await routes . get ( 'reportshq.download' ) ! . handle ( { params : { slug : 'sales' , format : 'csv' } } ) 208 + await routes . get ( 'reportshq.draft' ) ! . handle ( { params : { slug : 'sales' } } ) 209 + await routes . get ( 'reportshq.blocks.add' ) ! . handle ( { params : { slug : 'sales' } , body : { kind : 'bar' } } ) 210 + await routes . get ( 'reportshq.blocks.save' ) ! . handle ( { params : { slug : 'sales' , block : '42' } , body : { title : 'Sales' } } ) 211 + await routes . get ( 'reportshq.blocks.remove' ) ! . handle ( { params : { slug : 'sales' , block : '42' } } ) 212 + await routes . get ( 'reportshq.publish' ) ! . handle ( { params : { slug : 'sales' } } ) 213 + await routes . get ( 'reportshq.layout' ) ! . handle ( { params : { slug : 'sales' } , body : { blocks : [ { id : 42 } ] } } ) 214 + await routes . get ( 'reportshq.show' ) ! . handle ( { params : { slug : 'sales' } } ) 215 + 216 + expect ( calls ) . toEqual ( [ 217 + [ 'index' ] , 218 + [ 'schema' ] , 219 + [ 'download' , 'sales' , 'csv' ] , 220 + [ 'show' , 'sales' , false ] , 221 + [ 'add' , 'sales' , 'bar' ] , 222 + [ 'save' , 'sales' , 42 , { title : 'Sales' } ] , 223 + [ 'remove' , 'sales' , 42 ] , 224 + [ 'publish' , 'sales' ] , 225 + [ 'layout' , 'sales' , [ { id : 42 } ] ] , 226 + [ 'show' , 'sales' , true ] , 227 + ] ) 228 + } ) 101 229 } ) 102 230 103 231 describe ( 'elements' , ( ) => {