1- import type { ProtectClient } from '@cipherstash/protect/client'
2- import { PgDialect , pgTable } from 'drizzle-orm/pg-core'
3- import { describe , expect , it , vi } from 'vitest'
4- import { createProtectOperators , encryptedType } from '../src/pg'
1+ import { pgTable } from 'drizzle-orm/pg-core'
2+ import { describe , expect , it } from 'vitest'
3+ import { encryptedType } from '../src/pg'
4+ import { ProtectOperatorError } from '../src/pg/operators'
5+ import { setup } from './test-utils'
56
67const docsTable = pgTable ( 'json_docs' , {
78 metadata : encryptedType < Record < string , unknown > > ( 'metadata' , {
89 dataType : 'json' ,
910 searchableJson : true ,
1011 } ) ,
12+ noJsonConfig : encryptedType < string > ( 'no_json_config' , {
13+ equality : true ,
14+ } ) ,
1115} )
1216
13- function createMockProtectClient ( ) {
14- const encryptedSelector = '{"v":"encrypted-selector"}'
15- const encryptQuery = vi . fn ( async ( terms : unknown [ ] ) => ( {
16- data : terms . map ( ( ) => encryptedSelector ) ,
17- } ) )
18-
19- return {
20- client : { encryptQuery } as unknown as ProtectClient ,
21- encryptQuery,
22- encryptedSelector,
23- }
24- }
25-
2617describe ( 'createProtectOperators JSONB selector typing' , ( ) => {
2718 it ( 'casts jsonbPathQueryFirst selector params to eql_v2_encrypted' , async ( ) => {
28- const { client, encryptQuery, encryptedSelector } =
29- createMockProtectClient ( )
30- const protectOps = createProtectOperators ( client )
31- const dialect = new PgDialect ( )
19+ const { encryptQuery, protectOps, dialect } = setup ( )
3220
3321 const condition = await protectOps . jsonbPathQueryFirst (
3422 docsTable . metadata ,
@@ -40,17 +28,15 @@ describe('createProtectOperators JSONB selector typing', () => {
4028 / e q l _ v 2 \. j s o n b _ p a t h _ q u e r y _ f i r s t \( [ ^ , ] + , \s * \$ \d + : : e q l _ v 2 _ e n c r y p t e d \) / ,
4129 )
4230 expect ( query . params ) . toHaveLength ( 1 )
43- expect ( typeof query . params [ 0 ] ) . toBe ( 'string ')
31+ expect ( query . params [ 0 ] ) . toContain ( 'encrypted-value ')
4432 expect ( encryptQuery ) . toHaveBeenCalledTimes ( 1 )
4533 expect ( encryptQuery . mock . calls [ 0 ] ?. [ 0 ] ) . toMatchObject ( [
4634 { queryType : 'steVecSelector' } ,
4735 ] )
4836 } )
4937
5038 it ( 'casts jsonbPathExists selector params to eql_v2_encrypted' , async ( ) => {
51- const { client } = createMockProtectClient ( )
52- const protectOps = createProtectOperators ( client )
53- const dialect = new PgDialect ( )
39+ const { protectOps, dialect } = setup ( )
5440
5541 const condition = await protectOps . jsonbPathExists (
5642 docsTable . metadata ,
@@ -62,13 +48,11 @@ describe('createProtectOperators JSONB selector typing', () => {
6248 / e q l _ v 2 \. j s o n b _ p a t h _ e x i s t s \( [ ^ , ] + , \s * \$ \d + : : e q l _ v 2 _ e n c r y p t e d \) / ,
6349 )
6450 expect ( query . params ) . toHaveLength ( 1 )
65- expect ( typeof query . params [ 0 ] ) . toBe ( 'string ')
51+ expect ( query . params [ 0 ] ) . toContain ( 'encrypted-value ')
6652 } )
6753
6854 it ( 'casts jsonbGet selector params to eql_v2_encrypted' , async ( ) => {
69- const { client } = createMockProtectClient ( )
70- const protectOps = createProtectOperators ( client )
71- const dialect = new PgDialect ( )
55+ const { protectOps, dialect } = setup ( )
7256
7357 const condition = await protectOps . jsonbGet (
7458 docsTable . metadata ,
@@ -78,6 +62,108 @@ describe('createProtectOperators JSONB selector typing', () => {
7862
7963 expect ( query . sql ) . toMatch ( / - > \s * \$ \d + : : e q l _ v 2 _ e n c r y p t e d / )
8064 expect ( query . params ) . toHaveLength ( 1 )
81- expect ( typeof query . params [ 0 ] ) . toBe ( 'string' )
65+ expect ( query . params [ 0 ] ) . toContain ( 'encrypted-value' )
66+ } )
67+ } )
68+
69+ describe ( 'JSONB operator error paths' , ( ) => {
70+ it ( 'throws ProtectOperatorError when column lacks searchableJson config' , ( ) => {
71+ const { protectOps } = setup ( )
72+
73+ expect ( ( ) =>
74+ protectOps . jsonbPathQueryFirst ( docsTable . noJsonConfig , '$.path' ) ,
75+ ) . toThrow ( ProtectOperatorError )
76+
77+ expect ( ( ) =>
78+ protectOps . jsonbPathQueryFirst ( docsTable . noJsonConfig , '$.path' ) ,
79+ ) . toThrow ( / s e a r c h a b l e J s o n / )
80+ } )
81+
82+ it ( 'throws ProtectOperatorError for jsonbPathExists without searchableJson' , ( ) => {
83+ const { protectOps } = setup ( )
84+
85+ expect ( ( ) =>
86+ protectOps . jsonbPathExists ( docsTable . noJsonConfig , '$.path' ) ,
87+ ) . toThrow ( ProtectOperatorError )
88+ } )
89+
90+ it ( 'throws ProtectOperatorError for jsonbGet without searchableJson' , ( ) => {
91+ const { protectOps } = setup ( )
92+
93+ expect ( ( ) =>
94+ protectOps . jsonbGet ( docsTable . noJsonConfig , '$.path' ) ,
95+ ) . toThrow ( ProtectOperatorError )
96+ } )
97+
98+ it ( 'error includes column name and operator context' , ( ) => {
99+ const { protectOps } = setup ( )
100+
101+ try {
102+ protectOps . jsonbPathQueryFirst ( docsTable . noJsonConfig , '$.path' )
103+ expect . fail ( 'Should have thrown' )
104+ } catch ( error ) {
105+ expect ( error ) . toBeInstanceOf ( ProtectOperatorError )
106+ const opError = error as ProtectOperatorError
107+ expect ( opError . context ?. columnName ) . toBe ( 'no_json_config' )
108+ expect ( opError . context ?. operator ) . toBe ( 'jsonbPathQueryFirst' )
109+ }
110+ } )
111+ } )
112+
113+ describe ( 'JSONB batched operations' , ( ) => {
114+ it ( 'batches jsonbPathQueryFirst and jsonbGet in protectOps.and()' , async ( ) => {
115+ const { encryptQuery, protectOps, dialect } = setup ( )
116+
117+ const condition = await protectOps . and (
118+ protectOps . jsonbPathQueryFirst ( docsTable . metadata , '$.profile.email' ) ,
119+ protectOps . jsonbGet ( docsTable . metadata , '$.profile.name' ) ,
120+ )
121+ const query = dialect . sqlToQuery ( condition )
122+
123+ expect ( query . sql ) . toContain ( 'eql_v2.jsonb_path_query_first' )
124+ expect ( query . sql ) . toContain ( '->' )
125+ // Both values should be encrypted
126+ expect ( encryptQuery ) . toHaveBeenCalled ( )
127+ } )
128+
129+ it ( 'batches jsonbPathExists and jsonbPathQueryFirst in protectOps.or()' , async ( ) => {
130+ const { encryptQuery, protectOps, dialect } = setup ( )
131+
132+ const condition = await protectOps . or (
133+ protectOps . jsonbPathExists ( docsTable . metadata , '$.profile.email' ) ,
134+ protectOps . jsonbPathQueryFirst ( docsTable . metadata , '$.profile.name' ) ,
135+ )
136+ const query = dialect . sqlToQuery ( condition )
137+
138+ expect ( query . sql ) . toContain ( 'eql_v2.jsonb_path_exists' )
139+ expect ( query . sql ) . toContain ( 'eql_v2.jsonb_path_query_first' )
140+ // Both values should be encrypted
141+ expect ( encryptQuery ) . toHaveBeenCalled ( )
142+ } )
143+
144+ it ( 'generates SQL combining conditions with AND' , async ( ) => {
145+ const { protectOps, dialect } = setup ( )
146+
147+ const condition = await protectOps . and (
148+ protectOps . jsonbPathQueryFirst ( docsTable . metadata , '$.a' ) ,
149+ protectOps . jsonbPathExists ( docsTable . metadata , '$.b' ) ,
150+ )
151+ const query = dialect . sqlToQuery ( condition )
152+
153+ // AND combines conditions
154+ expect ( query . sql ) . toContain ( ' and ' )
155+ } )
156+
157+ it ( 'generates SQL combining conditions with OR' , async ( ) => {
158+ const { protectOps, dialect } = setup ( )
159+
160+ const condition = await protectOps . or (
161+ protectOps . jsonbPathQueryFirst ( docsTable . metadata , '$.a' ) ,
162+ protectOps . jsonbPathExists ( docsTable . metadata , '$.b' ) ,
163+ )
164+ const query = dialect . sqlToQuery ( condition )
165+
166+ // OR combines conditions
167+ expect ( query . sql ) . toContain ( ' or ' )
82168 } )
83169} )
0 commit comments