Skip to content

Commit 984da0c

Browse files
committed
test(api): add integration tests for array read endpoints (RI-8219)
Covers the seven Array read endpoints landed in PR #6064: - POST /array/get-range (ARGETRANGE) - POST /array/scan (ARSCAN) - POST /array/get-length (ARLEN) - POST /array/get-count (ARCOUNT) - POST /array/get-next-index (ARNEXT) - POST /array/get-element (ARGET) - POST /array/get-elements (ARMGET) Locks in on the wire format the unit specs can't reach: - decimal-string contract for length/count/scan-index/next-index (Joi.string().pattern(/^\d+$/) plus a u64 boundary case at index 9223372036854775818 to catch silent numeric coercion); - gap-preserving semantics — empty slots surface as JSON null in get-range / get-element / get-elements (and are skipped by scan); - RedisStringToBufferTransformer null passthrough — encoding=buffer on an empty slot must stay JSON null, not a zero-length Buffer; - explicit limit:null on scan is treated as omitted (regression guard for the `typeof limit === 'number'` gate); - 400 cases hardened in PR #6064: ARRAY_RANGE_REVERSED, range > 1M (ARRAY_RANGE_TOO_LARGE), ARMGET @ArrayMinSize(1) and the @ArrayMaxSize(ARRAY_RANGE_MAX_ELEMENTS) cap, non-decimal and out-of-u64 indexes; - WrongType, missing key, missing instance per endpoint; - per-command ACL denials (-arget, -arlen, -arcount, -arnext, -armget, -argetrange, -arscan) gated by requirements('rte.acl'). Also extends keys/POST-databases-id-keys-get_info.test.ts with an Array describe block (gated by rte.version>=8.8) that seeds a dense and a sparse Array key and pins the new GetArrayKeyInfoResponse oneOf branch — length === count for dense, length !== count for sparse, both as decimal strings.
1 parent d3cdb3f commit 984da0c

8 files changed

Lines changed: 1415 additions & 0 deletions
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import {
2+
describe,
3+
it,
4+
before,
5+
deps,
6+
Joi,
7+
requirements,
8+
generateInvalidDataTestCases,
9+
validateInvalidDataTestCase,
10+
validateApiCall,
11+
getMainCheckFn,
12+
JoiRedisString,
13+
} from '../deps';
14+
15+
const { server, request, constants } = deps;
16+
const rte = deps.rte as any;
17+
18+
const endpoint = (instanceId = constants.TEST_INSTANCE_ID) =>
19+
request(server).post(
20+
`/${constants.API.DATABASES}/${instanceId}/array/get-count`,
21+
);
22+
23+
const dataSchema = Joi.object({
24+
keyName: Joi.string().allow('').required(),
25+
}).strict();
26+
27+
const validInputData = {
28+
keyName: constants.getRandomString(),
29+
};
30+
31+
const responseSchema = Joi.object()
32+
.keys({
33+
keyName: JoiRedisString.required(),
34+
count: Joi.string().pattern(/^\d+$/).required(),
35+
})
36+
.required();
37+
38+
const mainCheckFn = getMainCheckFn(endpoint);
39+
40+
describe('POST /databases/:instanceId/array/get-count', () => {
41+
requirements('rte.version>=8.8');
42+
beforeEach(async () => rte.data.truncate());
43+
44+
describe('Validation', () => {
45+
generateInvalidDataTestCases(dataSchema, validInputData).map(
46+
validateInvalidDataTestCase(endpoint, dataSchema),
47+
);
48+
});
49+
50+
describe('Main', () => {
51+
it('Should return count equal to length for a dense array', async () => {
52+
const keyName = constants.getRandomString();
53+
await rte.client.call('ARSET', keyName, '0', 'a', 'b', 'c');
54+
55+
await validateApiCall({
56+
endpoint,
57+
data: { keyName },
58+
responseSchema,
59+
responseBody: { keyName, count: '3' },
60+
});
61+
});
62+
63+
it('Should diverge from length for a sparse array', async () => {
64+
const keyName = constants.getRandomString();
65+
// Sparse: indexes 0,1,5 populated → count=3, length=6. The point of
66+
// ARCOUNT is that it stays cheap even when length grows; pinning the
67+
// divergence locks in that the two commands are surfaced independently.
68+
await rte.client.call(
69+
'ARMSET',
70+
keyName,
71+
'0',
72+
'20.1',
73+
'1',
74+
'20.4',
75+
'5',
76+
'21.4',
77+
);
78+
79+
await validateApiCall({
80+
endpoint,
81+
data: { keyName },
82+
responseSchema,
83+
responseBody: { keyName, count: '3' },
84+
});
85+
});
86+
87+
[
88+
{
89+
name: 'Should return BadRequest if key holds a non-array type',
90+
data: { keyName: constants.TEST_STRING_KEY_1 },
91+
statusCode: 400,
92+
before: () => rte.data.generateKeys(true),
93+
},
94+
{
95+
name: 'Should return NotFound if key does not exist',
96+
data: { keyName: constants.getRandomString() },
97+
statusCode: 404,
98+
responseBody: {
99+
statusCode: 404,
100+
error: 'Not Found',
101+
message: 'Key with this name does not exist.',
102+
},
103+
},
104+
{
105+
name: 'Should return NotFound if instance id does not exist',
106+
endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID),
107+
data: { keyName: constants.getRandomString() },
108+
statusCode: 404,
109+
responseBody: {
110+
statusCode: 404,
111+
error: 'Not Found',
112+
message: 'Invalid database instance id.',
113+
},
114+
},
115+
].map(mainCheckFn);
116+
});
117+
118+
describe('ACL', () => {
119+
requirements('rte.acl');
120+
before(async () => rte.data.setAclUserRules('~* +@all'));
121+
122+
const aclEndpoint = () => endpoint(constants.TEST_INSTANCE_ACL_ID);
123+
const aclKey = constants.getRandomString();
124+
125+
[
126+
{
127+
name: 'Should return count for an authorised user',
128+
endpoint: aclEndpoint,
129+
data: { keyName: aclKey },
130+
responseSchema,
131+
before: async () => {
132+
await rte.data.setAclUserRules('~* +@all');
133+
await rte.client.call('ARSET', aclKey, '0', 'x');
134+
},
135+
},
136+
{
137+
name: 'Should throw error if no permissions for "arcount" command',
138+
endpoint: aclEndpoint,
139+
data: { keyName: aclKey },
140+
statusCode: 403,
141+
responseBody: { statusCode: 403, error: 'Forbidden' },
142+
before: () => rte.data.setAclUserRules('~* +@all -arcount'),
143+
},
144+
].map(mainCheckFn);
145+
});
146+
});
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import {
2+
expect,
3+
describe,
4+
it,
5+
before,
6+
deps,
7+
Joi,
8+
requirements,
9+
generateInvalidDataTestCases,
10+
validateInvalidDataTestCase,
11+
validateApiCall,
12+
getMainCheckFn,
13+
JoiRedisString,
14+
} from '../deps';
15+
16+
const { server, request, constants } = deps;
17+
const rte = deps.rte as any;
18+
19+
const endpoint = (instanceId = constants.TEST_INSTANCE_ID) =>
20+
request(server).post(
21+
`/${constants.API.DATABASES}/${instanceId}/array/get-element`,
22+
);
23+
24+
const dataSchema = Joi.object({
25+
keyName: Joi.string().allow('').required(),
26+
index: Joi.string().required(),
27+
}).strict();
28+
29+
const validInputData = {
30+
keyName: constants.getRandomString(),
31+
index: '0',
32+
};
33+
34+
// Empty slots and out-of-range indexes return value: null (key still exists);
35+
// only a missing key turns into a 404.
36+
const responseSchema = Joi.object()
37+
.keys({
38+
keyName: JoiRedisString.required(),
39+
value: JoiRedisString.allow(null).required(),
40+
})
41+
.required();
42+
43+
const mainCheckFn = getMainCheckFn(endpoint);
44+
45+
// Seed shape mirrors the canonical `readings` fixture documented in the Bruno
46+
// presets — indexes 0,1,5 populated, gaps at 2,3,4.
47+
const seedSparse = (key: string) =>
48+
rte.client.call('ARMSET', key, '0', '20.1', '1', '20.4', '5', '21.4');
49+
50+
describe('POST /databases/:instanceId/array/get-element', () => {
51+
requirements('rte.version>=8.8');
52+
beforeEach(async () => rte.data.truncate());
53+
54+
describe('Validation', () => {
55+
generateInvalidDataTestCases(dataSchema, validInputData).map(
56+
validateInvalidDataTestCase(endpoint, dataSchema),
57+
);
58+
59+
[
60+
{
61+
name: 'Should reject a non-decimal index',
62+
data: { keyName: constants.getRandomString(), index: 'abc' },
63+
statusCode: 400,
64+
},
65+
{
66+
name: 'Should reject a non-canonical index with leading zero',
67+
data: { keyName: constants.getRandomString(), index: '007' },
68+
statusCode: 400,
69+
},
70+
{
71+
name: 'Should reject an index outside u64',
72+
data: {
73+
keyName: constants.getRandomString(),
74+
index: '18446744073709551616',
75+
},
76+
statusCode: 400,
77+
},
78+
].map(mainCheckFn);
79+
});
80+
81+
describe('Main', () => {
82+
it('Should return value for a populated index', async () => {
83+
const keyName = constants.getRandomString();
84+
await seedSparse(keyName);
85+
86+
await validateApiCall({
87+
endpoint,
88+
data: { keyName, index: '1' },
89+
responseSchema,
90+
responseBody: { keyName, value: '20.4' },
91+
});
92+
});
93+
94+
it('Should return null for an empty slot within the array', async () => {
95+
const keyName = constants.getRandomString();
96+
await seedSparse(keyName);
97+
98+
// Index 3 sits inside the array's logical length (0..5) but is unset.
99+
// Contract: 200 OK with value:null, NOT 404 — key exists, slot is empty.
100+
await validateApiCall({
101+
endpoint,
102+
data: { keyName, index: '3' },
103+
responseSchema,
104+
responseBody: { keyName, value: null },
105+
});
106+
});
107+
108+
it('Should return null for an index past the array length', async () => {
109+
const keyName = constants.getRandomString();
110+
await seedSparse(keyName);
111+
112+
// Index 999 is past length=6. ARGET still returns nil rather than
113+
// erroring out — same contract as an empty slot inside the array.
114+
await validateApiCall({
115+
endpoint,
116+
data: { keyName, index: '999' },
117+
responseSchema,
118+
responseBody: { keyName, value: null },
119+
});
120+
});
121+
122+
it('Should return a Buffer object when encoding=buffer on a populated slot', async () => {
123+
const keyName = constants.getRandomString();
124+
await seedSparse(keyName);
125+
126+
await validateApiCall({
127+
endpoint,
128+
query: { encoding: 'buffer' },
129+
data: { keyName, index: '0' },
130+
responseSchema,
131+
checkFn: ({ body }: any) => {
132+
// Buffer-mode populated value comes back as the {type, data} shape
133+
// produced by JSON-serializing a Node Buffer.
134+
expect(body.value).to.eql({
135+
type: 'Buffer',
136+
data: [...Buffer.from('20.1')],
137+
});
138+
},
139+
});
140+
});
141+
142+
it('Should keep an empty slot as JSON null even when encoding=buffer', async () => {
143+
const keyName = constants.getRandomString();
144+
await seedSparse(keyName);
145+
146+
// Locks in the RedisStringToBufferTransformer null-passthrough fix:
147+
// a nil ARGET reply must not be coerced into a zero-length Buffer.
148+
await validateApiCall({
149+
endpoint,
150+
query: { encoding: 'buffer' },
151+
data: { keyName, index: '3' },
152+
responseSchema,
153+
checkFn: ({ body }: any) => {
154+
expect(body.value).to.eql(null);
155+
},
156+
});
157+
});
158+
159+
[
160+
{
161+
name: 'Should return BadRequest if key holds a non-array type',
162+
data: { keyName: constants.TEST_STRING_KEY_1, index: '0' },
163+
statusCode: 400,
164+
before: () => rte.data.generateKeys(true),
165+
},
166+
{
167+
name: 'Should return NotFound if key does not exist',
168+
data: { keyName: constants.getRandomString(), index: '0' },
169+
statusCode: 404,
170+
responseBody: {
171+
statusCode: 404,
172+
error: 'Not Found',
173+
message: 'Key with this name does not exist.',
174+
},
175+
},
176+
{
177+
name: 'Should return NotFound if instance id does not exist',
178+
endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID),
179+
data: { keyName: constants.getRandomString(), index: '0' },
180+
statusCode: 404,
181+
responseBody: {
182+
statusCode: 404,
183+
error: 'Not Found',
184+
message: 'Invalid database instance id.',
185+
},
186+
},
187+
].map(mainCheckFn);
188+
});
189+
190+
describe('ACL', () => {
191+
requirements('rte.acl');
192+
before(async () => rte.data.setAclUserRules('~* +@all'));
193+
194+
const aclEndpoint = () => endpoint(constants.TEST_INSTANCE_ACL_ID);
195+
const aclKey = constants.getRandomString();
196+
197+
[
198+
{
199+
name: 'Should return value for an authorised user',
200+
endpoint: aclEndpoint,
201+
data: { keyName: aclKey, index: '0' },
202+
responseSchema,
203+
before: async () => {
204+
await rte.data.setAclUserRules('~* +@all');
205+
await rte.client.call('ARSET', aclKey, '0', 'x');
206+
},
207+
},
208+
{
209+
name: 'Should throw error if no permissions for "arget" command',
210+
endpoint: aclEndpoint,
211+
data: { keyName: aclKey, index: '0' },
212+
statusCode: 403,
213+
responseBody: { statusCode: 403, error: 'Forbidden' },
214+
before: () => rte.data.setAclUserRules('~* +@all -arget'),
215+
},
216+
].map(mainCheckFn);
217+
});
218+
});

0 commit comments

Comments
 (0)