@@ -5,6 +5,7 @@ import { describe, it, expect, vi } from 'vitest';
55// entered a tsc program (#5449).
66import { WELL_KNOWN_CAPABILITY_KEYS } from '@objectstack/spec/api' ;
77import { ObjectStackClient , createQuery , createFilter } from './index' ;
8+ import type { QueryOptions , QueryOptionsV2 } from './index' ;
89
910/** Helper: create a client with mocked fetch that returns the given response body */
1011function createMockClient ( body : any , status = 200 ) {
@@ -1226,50 +1227,181 @@ describe('ObjectStackClient.automation', () => {
12261227// QueryOptionsV2 (Canonical Query Syntax) Tests
12271228// ==========================================
12281229
1229- describe ( 'QueryOptionsV2 — canonical find()' , ( ) => {
1230- it ( 'should accept canonical field names (where, fields, orderBy, limit, offset)' , async ( ) => {
1231- const { client, fetchMock } = createMockClient ( {
1232- success : true ,
1233- data : { object : 'account' , records : [ ] , total : 0 }
1234- } ) ;
1230+ /**
1231+ * `data.find()` transport parameters — ONE expectation table, BOTH copies.
1232+ *
1233+ * `find` is implemented twice: `ObjectStackClient.data.find` and
1234+ * `ScopedProjectClient.data.find`. They are two faces of ONE wire contract
1235+ * (the scoped one differs only in the URL prefix) and were byte-identical
1236+ * copies of the same normalization — including the same defect. Every row
1237+ * below is therefore driven through BOTH and compared against the SAME
1238+ * expected query string, so a future edit that lands on only one of them goes
1239+ * red here instead of shipping a fork.
1240+ *
1241+ * The expectations are EXACT full query strings, not `toContain` substrings:
1242+ * the defect this suite was written for (#6322) was a param that never
1243+ * appeared at all, and a substring assertion on the params that DID appear
1244+ * stays green through exactly that.
1245+ *
1246+ * Seeded from the measurements in #6322.
1247+ */
1248+ describe ( 'data.find() — canonical/legacy transport parameters (both copies)' , ( ) => {
1249+ /** The query string a call put on the wire (`''` when it sent none). */
1250+ function queryOf ( url : string ) : string {
1251+ const q = url . indexOf ( '?' ) ;
1252+ return q === - 1 ? '' : url . slice ( q + 1 ) ;
1253+ }
12351254
1236- await client . data . find ( 'account' , {
1237- where : { status : 'active' } ,
1238- fields : [ 'name' , 'email' ] ,
1239- orderBy : [ '-created_at' ] ,
1240- limit : 10 ,
1241- offset : 5 ,
1255+ /** Drive the SAME options through both `find` implementations. */
1256+ async function driveBoth ( options : QueryOptions | QueryOptionsV2 ) : Promise < { direct : string ; scoped : string } > {
1257+ const body = { success : true , data : { object : 'task' , records : [ ] , total : 0 } } ;
1258+
1259+ const a = createMockClient ( body ) ;
1260+ await a . client . data . find ( 'task' , options ) ;
1261+ const direct = queryOf ( a . fetchMock . mock . calls [ 0 ] [ 0 ] as string ) ;
1262+
1263+ const b = createMockClient ( body ) ;
1264+ await b . client . project ( 'env-1' ) . data . find ( 'task' , options ) ;
1265+ const scoped = queryOf ( b . fetchMock . mock . calls [ 0 ] [ 0 ] as string ) ;
1266+
1267+ return { direct, scoped } ;
1268+ }
1269+
1270+ interface TransportRow {
1271+ options : QueryOptions | QueryOptionsV2 ;
1272+ /** The exact query string both copies must emit. */
1273+ wire : string ;
1274+ }
1275+
1276+ /**
1277+ * A key declared on `QueryOptionsV2` and on NO legacy `QueryOptions`
1278+ * spelling — recomputed here from the two exported interfaces rather than
1279+ * restated, so it tracks them.
1280+ */
1281+ type CanonicalOnlyKey = Exclude < keyof QueryOptionsV2 , keyof QueryOptions > ;
1282+
1283+ /**
1284+ * ONE ROW PER CANONICAL-ONLY KEY, DRIVEN ALONE. This is the shape of the
1285+ * #6322 defect: `find('task', { limit: 20 })` — a canonical key as the
1286+ * ONLY key — was not recognised as canonical vocabulary, fell to the
1287+ * legacy branch, which reads no `limit`, and reached the server with an
1288+ * EMPTY query string. HTTP 200, server default page size, no warning.
1289+ * Its pagination twin `{ offset: 5 }` worked, because `offset` happened to
1290+ * be in the hand-written sniff list and `limit` did not.
1291+ *
1292+ * `Record<CanonicalOnlyKey, …>` is the anti-rot device: a new key added to
1293+ * `QueryOptionsV2` makes this object a COMPILE error until someone states
1294+ * what that key puts on the wire — which is the question the old
1295+ * hand-maintained sniff list let two keys (`limit`, `expand`) slip past.
1296+ */
1297+ const SINGLE_CANONICAL_KEY : Record < CanonicalOnlyKey , TransportRow > = {
1298+ where : { options : { where : { contact_id : 'c1' } } , wire : 'contact_id=c1' } ,
1299+ fields : { options : { fields : [ 'id' , 'amount' ] } , wire : 'select=id%2Camount' } ,
1300+ orderBy : { options : { orderBy : [ '-created_at' ] } , wire : 'sort=-created_at' } ,
1301+ limit : { options : { limit : 20 } , wire : 'top=20' } ,
1302+ offset : { options : { offset : 5 } , wire : 'skip=5' } ,
1303+ expand : { options : { expand : [ 'contact' ] } , wire : 'expand=contact' } ,
1304+ } ;
1305+
1306+ /** The five paired keys, canonical spelling. */
1307+ const CANONICAL_FULL : QueryOptionsV2 = {
1308+ where : { contact_id : 'c1' } ,
1309+ fields : [ 'id' , 'amount' ] ,
1310+ orderBy : [ '-created_at' ] ,
1311+ limit : 20 ,
1312+ offset : 5 ,
1313+ } ;
1314+
1315+ /** The same query, legacy spelling. Must reach the wire byte-identically. */
1316+ const LEGACY_FULL : QueryOptions = {
1317+ filter : { contact_id : 'c1' } ,
1318+ select : [ 'id' , 'amount' ] ,
1319+ sort : [ '-created_at' ] ,
1320+ top : 20 ,
1321+ skip : 5 ,
1322+ } ;
1323+
1324+ const ROWS : Record < string , TransportRow > = {
1325+ 'canonical: where + fields + orderBy + limit + offset' : {
1326+ options : CANONICAL_FULL ,
1327+ wire : 'top=20&skip=5&sort=-created_at&select=id%2Camount&contact_id=c1' ,
1328+ } ,
1329+ 'legacy: filter + select + sort + top + skip' : {
1330+ options : LEGACY_FULL ,
1331+ wire : 'top=20&skip=5&sort=-created_at&select=id%2Camount&contact_id=c1' ,
1332+ } ,
1333+ ...Object . fromEntries (
1334+ Object . entries ( SINGLE_CANONICAL_KEY ) . map ( ( [ key , row ] ) => [ `canonical single key: { ${ key } }` , row ] ) ,
1335+ ) ,
1336+ // The legacy twins of the two pagination keys — pinned alongside so a
1337+ // change that fixes one vocabulary by breaking the other goes red.
1338+ 'legacy single key: { top }' : { options : { top : 20 } , wire : 'top=20' } ,
1339+ 'legacy single key: { skip }' : { options : { skip : 5 } , wire : 'skip=5' } ,
1340+ 'canonical: where + limit' : {
1341+ options : { where : { contact_id : 'c1' } , limit : 20 } ,
1342+ wire : 'top=20&contact_id=c1' ,
1343+ } ,
1344+ 'canonical: where + expand' : {
1345+ options : { where : { contact_id : 'c1' } , expand : [ 'contact' ] } ,
1346+ wire : 'contact_id=c1&expand=contact' ,
1347+ } ,
1348+ // `expand` reaches the wire as `expand=<comma-separated relation
1349+ // names>` — the one spelling `HttpFindQueryParamsSchema` declares for
1350+ // the GET list route and the protocol normalizer splits on commas.
1351+ 'canonical: expand as a name array' : {
1352+ options : { expand : [ 'contact' , 'owner' ] } ,
1353+ wire : 'expand=contact%2Cowner' ,
1354+ } ,
1355+ // The `Record` form contributes its KEYS — the same relation names the
1356+ // server derives from the comma list.
1357+ 'canonical: expand as a relation map' : {
1358+ options : { expand : { contact : { } , owner : { } } } ,
1359+ wire : 'expand=contact%2Cowner' ,
1360+ } ,
1361+ 'no options at all' : { options : { } , wire : '' } ,
1362+ } ;
1363+
1364+ for ( const [ name , row ] of Object . entries ( ROWS ) ) {
1365+ it ( `${ name } → \`${ row . wire } \` on both copies` , async ( ) => {
1366+ const { direct, scoped } = await driveBoth ( row . options ) ;
1367+ expect ( direct ) . toBe ( row . wire ) ;
1368+ expect ( scoped ) . toBe ( row . wire ) ;
12421369 } ) ;
1370+ }
12431371
1244- const url = fetchMock . mock . calls [ 0 ] [ 0 ] as string ;
1245- // V2 canonical options are normalized to HTTP transport params
1246- expect ( url ) . toContain ( 'top=10' ) ;
1247- expect ( url ) . toContain ( 'skip=5' ) ;
1248- expect ( url ) . toContain ( 'select=name%2Cemail' ) ;
1249- expect ( url ) . toContain ( 'sort=-created_at' ) ;
1250- // where → filter as JSON
1251- expect ( url ) . toContain ( 'status=active' ) ;
1372+ it ( 'canonical and legacy spellings of one query are byte-identical on the wire' , async ( ) => {
1373+ const canonical = await driveBoth ( CANONICAL_FULL ) ;
1374+ const legacy = await driveBoth ( LEGACY_FULL ) ;
1375+ expect ( canonical . direct ) . toBe ( legacy . direct ) ;
1376+ expect ( canonical . scoped ) . toBe ( legacy . scoped ) ;
12521377 } ) ;
12531378
1254- it ( 'should still accept legacy field names (filter, select, sort, top, skip)' , async ( ) => {
1255- const { client, fetchMock } = createMockClient ( {
1256- success : true ,
1257- data : { object : 'account' , records : [ ] , total : 0 }
1258- } ) ;
1379+ /**
1380+ * A nested per-relation query inside `expand` has no spelling on a GET, so
1381+ * it is REFUSED rather than trimmed away — trimming would send a wider
1382+ * read than the caller asked for and say nothing.
1383+ *
1384+ * This is a client-side pre-flight guard, not an HTTP rejection, so there
1385+ * is no ADR-0112 `code`/`status` envelope to assert. The two independent
1386+ * bits asserted instead: the message names the offending relation AND the
1387+ * nested keys it could not carry, and NO request was issued — so a
1388+ * "refusal" that fired after the read had already gone out, or one that
1389+ * resolved instead of throwing, both go red.
1390+ */
1391+ it ( 'refuses a nested per-relation expand on both copies, before any request' , async ( ) => {
1392+ const nested : QueryOptionsV2 = { expand : { contact : { fields : [ 'name' ] } } } ;
12591393
1260- await client . data . find ( 'account' , {
1261- filter : { industry : 'Tech' } ,
1262- select : [ 'name' ] ,
1263- sort : [ '-revenue' ] ,
1264- top : 20 ,
1265- skip : 0 ,
1266- } ) ;
1394+ const a = createMockClient ( { success : true , data : { object : 'task' , records : [ ] } } ) ;
1395+ await expect ( a . client . data . find ( 'task' , nested ) ) . rejects . toThrow (
1396+ / e x p a n d \[ ' c o n t a c t ' \] c a r r i e s a n e s t e d q u e r y \( f i e l d s \) / ,
1397+ ) ;
1398+ expect ( a . fetchMock ) . not . toHaveBeenCalled ( ) ;
12671399
1268- const url = fetchMock . mock . calls [ 0 ] [ 0 ] as string ;
1269- expect ( url ) . toContain ( 'top=20' ) ;
1270- expect ( url ) . toContain ( 'select=name' ) ;
1271- expect ( url ) . toContain ( 'sort=-revenue' ) ;
1272- expect ( url ) . toContain ( 'industry=Tech' ) ;
1400+ const b = createMockClient ( { success : true , data : { object : 'task' , records : [ ] } } ) ;
1401+ await expect ( b . client . project ( 'env-1' ) . data . find ( 'task' , nested ) ) . rejects . toThrow (
1402+ / e x p a n d \[ ' c o n t a c t ' \] c a r r i e s a n e s t e d q u e r y \( f i e l d s \) / ,
1403+ ) ;
1404+ expect ( b . fetchMock ) . not . toHaveBeenCalled ( ) ;
12731405 } ) ;
12741406} ) ;
12751407
0 commit comments