Skip to content

Commit 5934d84

Browse files
fix(#401,#395): standardize API envelope and add transient storage (#479)
Issue #401 - Standardize API response envelope across all backend endpoints: - Add backend/services/apiResponse.ts with ApiResponse<T> envelope type, ok()/fail()/fromError() helpers, ErrorCode union, ERROR_HTTP_STATUS_MAP, PaginationMeta, ResponseMeta, and backward-compat header constants - Add backend/services/apiClient.ts with typed ApiClient class, cursor pagination helper, and ApiClientError for typed error handling - Export all new types and helpers from backend/services/index.ts - Add comprehensive test suite (38 tests, all passing) - Add jest.backend.config.js for running backend tests without Expo preset Issue #395 - Refactor contract storage to use transient storage for gas optimization: - Add TmpLastCall, TmpProrationScratch, TmpChargeNonce keys to StorageKey enum in contracts/types/src/lib.rs (temporary storage tier, auto-expiring TTL) - Add temporary_get/set/remove/extend_ttl bridge methods to contracts/storage/src/lib.rs with implementation-auth guard - Migrate enforce_rate_limit in contracts/subscription/src/lib.rs from instance storage (StorageKey::LastCall) to temporary storage (StorageKey::TmpLastCall) with TTL = min_interval_secs converted to ledgers - Add secs_to_ledgers() helper and storage_temporary_get/set/remove helpers - Move ProxyScheduledUpgrade in contracts/proxy/src/storage.rs from instance to temporary storage with 7-day TTL (120960 ledgers) - Add contracts/storage/src/transient_storage_tests.rs with 11 regression tests - Add GAS_OPTIMIZATION_ANALYSIS.md with before/after analysis and migration guide - All Rust files pass rustfmt --check (syntactically valid) No external API or state guarantees changed. Backward-compatible. Co-authored-by: sandrawillow001-afk <sandrawillow001-afk@users.noreply.github.com>
1 parent e3e7b3f commit 5934d84

14 files changed

Lines changed: 1703 additions & 96 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/**
2+
* Tests for Issue #401 – Standardised API Response Envelope
3+
*/
4+
5+
import {
6+
ok,
7+
fail,
8+
fromError,
9+
buildMeta,
10+
ERROR_HTTP_STATUS_MAP,
11+
API_VERSION_HEADER,
12+
API_VERSION_VALUE,
13+
REQUEST_ID_HEADER,
14+
} from '../apiResponse';
15+
import type { ApiResponse, ApiSuccessResponse, ApiErrorResponse } from '../apiResponse';
16+
17+
// ─────────────────────────────────────────────────────────────────────────────
18+
// ok()
19+
// ─────────────────────────────────────────────────────────────────────────────
20+
21+
describe('ok()', () => {
22+
it('sets success to true', () => {
23+
const res = ok({ id: '1' });
24+
expect(res.success).toBe(true);
25+
});
26+
27+
it('includes the data payload', () => {
28+
const data = { id: '42', name: 'Pro' };
29+
const res = ok(data);
30+
expect(res.data).toEqual(data);
31+
});
32+
33+
it('generates a requestId when none is provided', () => {
34+
const res = ok({});
35+
expect(typeof res.meta.requestId).toBe('string');
36+
expect(res.meta.requestId.length).toBeGreaterThan(0);
37+
});
38+
39+
it('echoes the provided requestId', () => {
40+
const res = ok({}, 'req-abc-123');
41+
expect(res.meta.requestId).toBe('req-abc-123');
42+
});
43+
44+
it('sets apiVersion to 1', () => {
45+
expect(ok({}).meta.apiVersion).toBe(1);
46+
});
47+
48+
it('includes a valid ISO timestamp', () => {
49+
const res = ok({});
50+
expect(() => new Date(res.meta.timestamp)).not.toThrow();
51+
expect(new Date(res.meta.timestamp).toISOString()).toBe(res.meta.timestamp);
52+
});
53+
54+
it('attaches pagination metadata when provided', () => {
55+
const pagination = { cursor: 'tok_next', hasMore: true, total: 100 };
56+
const res = ok([], undefined, pagination);
57+
expect(res.meta.pagination).toEqual(pagination);
58+
});
59+
60+
it('omits pagination key when not provided', () => {
61+
const res = ok({});
62+
expect(res.meta.pagination).toBeUndefined();
63+
});
64+
65+
it('does not include an error field', () => {
66+
const res = ok({ x: 1 }) as ApiResponse<{ x: number }>;
67+
expect((res as ApiErrorResponse).error).toBeUndefined();
68+
});
69+
});
70+
71+
// ─────────────────────────────────────────────────────────────────────────────
72+
// fail()
73+
// ─────────────────────────────────────────────────────────────────────────────
74+
75+
describe('fail()', () => {
76+
it('sets success to false', () => {
77+
const res = fail('NOT_FOUND', 'Resource not found');
78+
expect(res.success).toBe(false);
79+
});
80+
81+
it('includes the error code', () => {
82+
const res = fail('SUBSCRIPTION_NOT_FOUND', 'Sub 99 not found');
83+
expect(res.error.code).toBe('SUBSCRIPTION_NOT_FOUND');
84+
});
85+
86+
it('includes the error message', () => {
87+
const res = fail('VALIDATION_ERROR', 'Price must be positive');
88+
expect(res.error.message).toBe('Price must be positive');
89+
});
90+
91+
it('echoes the provided requestId', () => {
92+
const res = fail('FORBIDDEN', 'Access denied', 'req-xyz');
93+
expect(res.meta.requestId).toBe('req-xyz');
94+
});
95+
96+
it('attaches field-level details when provided', () => {
97+
const details = { price: 'must be > 0', name: 'required' };
98+
const res = fail('VALIDATION_ERROR', 'Invalid input', undefined, details);
99+
expect(res.error.details).toEqual(details);
100+
});
101+
102+
it('omits details when not provided', () => {
103+
const res = fail('NOT_FOUND', 'Not found');
104+
expect(res.error.details).toBeUndefined();
105+
});
106+
107+
it('does not include a data field', () => {
108+
const res = fail('NOT_FOUND', 'Not found') as ApiResponse<unknown>;
109+
expect((res as ApiSuccessResponse<unknown>).data).toBeUndefined();
110+
});
111+
});
112+
113+
// ─────────────────────────────────────────────────────────────────────────────
114+
// fromError()
115+
// ─────────────────────────────────────────────────────────────────────────────
116+
117+
describe('fromError()', () => {
118+
it('converts an Error instance to INTERNAL_SERVER_ERROR', () => {
119+
const res = fromError(new Error('DB connection lost'));
120+
expect(res.success).toBe(false);
121+
expect(res.error.code).toBe('INTERNAL_SERVER_ERROR');
122+
expect(res.error.message).toBe('DB connection lost');
123+
});
124+
125+
it('handles non-Error thrown values', () => {
126+
const res = fromError('something went wrong');
127+
expect(res.error.code).toBe('INTERNAL_SERVER_ERROR');
128+
expect(res.error.message).toBe('An unexpected error occurred');
129+
});
130+
131+
it('echoes the requestId', () => {
132+
const res = fromError(new Error('oops'), 'req-err-1');
133+
expect(res.meta.requestId).toBe('req-err-1');
134+
});
135+
});
136+
137+
// ─────────────────────────────────────────────────────────────────────────────
138+
// buildMeta()
139+
// ─────────────────────────────────────────────────────────────────────────────
140+
141+
describe('buildMeta()', () => {
142+
it('generates a unique requestId each call when none is provided', () => {
143+
const a = buildMeta();
144+
const b = buildMeta();
145+
expect(a.requestId).not.toBe(b.requestId);
146+
});
147+
148+
it('uses the provided requestId', () => {
149+
const meta = buildMeta('my-req-id');
150+
expect(meta.requestId).toBe('my-req-id');
151+
});
152+
153+
it('sets apiVersion to 1', () => {
154+
expect(buildMeta().apiVersion).toBe(1);
155+
});
156+
});
157+
158+
// ─────────────────────────────────────────────────────────────────────────────
159+
// ERROR_HTTP_STATUS_MAP
160+
// ─────────────────────────────────────────────────────────────────────────────
161+
162+
describe('ERROR_HTTP_STATUS_MAP', () => {
163+
it('maps NOT_FOUND to 404', () => {
164+
expect(ERROR_HTTP_STATUS_MAP.NOT_FOUND).toBe(404);
165+
});
166+
167+
it('maps UNAUTHORIZED to 401', () => {
168+
expect(ERROR_HTTP_STATUS_MAP.UNAUTHORIZED).toBe(401);
169+
});
170+
171+
it('maps RATE_LIMIT_EXCEEDED to 429', () => {
172+
expect(ERROR_HTTP_STATUS_MAP.RATE_LIMIT_EXCEEDED).toBe(429);
173+
});
174+
175+
it('maps SUBSCRIPTION_CHARGE_FAILED to 402', () => {
176+
expect(ERROR_HTTP_STATUS_MAP.SUBSCRIPTION_CHARGE_FAILED).toBe(402);
177+
});
178+
179+
it('maps INTERNAL_SERVER_ERROR to 500', () => {
180+
expect(ERROR_HTTP_STATUS_MAP.INTERNAL_SERVER_ERROR).toBe(500);
181+
});
182+
183+
it('maps VALIDATION_ERROR to 422', () => {
184+
expect(ERROR_HTTP_STATUS_MAP.VALIDATION_ERROR).toBe(422);
185+
});
186+
187+
it('maps WEBHOOK_PAYLOAD_TOO_LARGE to 413', () => {
188+
expect(ERROR_HTTP_STATUS_MAP.WEBHOOK_PAYLOAD_TOO_LARGE).toBe(413);
189+
});
190+
191+
it('maps COUPON_EXPIRED to 410', () => {
192+
expect(ERROR_HTTP_STATUS_MAP.COUPON_EXPIRED).toBe(410);
193+
});
194+
});
195+
196+
// ─────────────────────────────────────────────────────────────────────────────
197+
// Header constants
198+
// ─────────────────────────────────────────────────────────────────────────────
199+
200+
describe('Header constants', () => {
201+
it('exports API_VERSION_HEADER', () => {
202+
expect(API_VERSION_HEADER).toBe('X-API-Version');
203+
});
204+
205+
it('exports API_VERSION_VALUE as "1"', () => {
206+
expect(API_VERSION_VALUE).toBe('1');
207+
});
208+
209+
it('exports REQUEST_ID_HEADER', () => {
210+
expect(REQUEST_ID_HEADER).toBe('X-Request-ID');
211+
});
212+
});
213+
214+
// ─────────────────────────────────────────────────────────────────────────────
215+
// Type-level: ApiResponse discriminated union
216+
// ─────────────────────────────────────────────────────────────────────────────
217+
218+
describe('ApiResponse discriminated union', () => {
219+
it('narrows to ApiSuccessResponse when success is true', () => {
220+
const res: ApiResponse<number> = ok(42);
221+
if (res.success) {
222+
// TypeScript should allow res.data here
223+
expect(res.data).toBe(42);
224+
}
225+
});
226+
227+
it('narrows to ApiErrorResponse when success is false', () => {
228+
const res: ApiResponse<number> = fail('NOT_FOUND', 'not found');
229+
if (!res.success) {
230+
expect(res.error.code).toBe('NOT_FOUND');
231+
}
232+
});
233+
});

0 commit comments

Comments
 (0)