test_hedging_sessions_post.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. /**
  2. * Contract test for POST /api/v1/hedging/sessions
  3. * Tests the API contract for creating hedging sessions
  4. */
  5. import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
  6. import axios, { AxiosInstance } from 'axios';
  7. import { CreateHedgingSessionRequest, CreateHedgingSessionResponse } from '../../src/types/hedging';
  8. describe('POST /api/v1/hedging/sessions', () => {
  9. let client: AxiosInstance;
  10. const baseURL = 'http://localhost:3000/api/v1/hedging';
  11. beforeAll(() => {
  12. client = axios.create({
  13. baseURL,
  14. headers: {
  15. 'Content-Type': 'application/json',
  16. 'Authorization': 'Bearer test-api-key'
  17. },
  18. timeout: 5000
  19. });
  20. });
  21. afterAll(() => {
  22. // Cleanup if needed
  23. });
  24. describe('Request Schema Validation', () => {
  25. it('should accept valid hedging session creation request', async () => {
  26. const validRequest: CreateHedgingSessionRequest = {
  27. name: 'Test Hedging Session',
  28. accountIds: ['account-1', 'account-2'],
  29. volumeTarget: 10000,
  30. strategy: {
  31. symbol: 'ETH/USD',
  32. volumeDistribution: 'equal',
  33. priceRange: {
  34. min: 0.001,
  35. max: 0.01
  36. },
  37. timing: {
  38. minInterval: 30,
  39. maxInterval: 120,
  40. orderSize: {
  41. min: 100,
  42. max: 500
  43. }
  44. },
  45. riskLimits: {
  46. maxPositionSize: 0.1,
  47. stopLossThreshold: 0.05,
  48. maxSlippage: 0.02
  49. },
  50. orderTypes: {
  51. primary: 'limit',
  52. fallback: 'market'
  53. }
  54. }
  55. };
  56. try {
  57. const response = await client.post('/sessions', validRequest);
  58. // Should return 201 Created
  59. expect(response.status).toBe(201);
  60. // Response should match schema
  61. const responseData: CreateHedgingSessionResponse = response.data;
  62. expect(responseData.success).toBe(true);
  63. expect(responseData.session).toBeDefined();
  64. expect(responseData.session.id).toBeDefined();
  65. expect(responseData.session.name).toBe(validRequest.name);
  66. expect(responseData.session.status).toBe('pending');
  67. expect(responseData.session.accounts).toEqual(validRequest.accountIds);
  68. expect(responseData.session.strategy).toEqual(validRequest.strategy);
  69. expect(responseData.session.volumeTarget).toBe(validRequest.volumeTarget);
  70. expect(responseData.session.volumeGenerated).toBe(0);
  71. expect(responseData.session.startTime).toBeDefined();
  72. expect(responseData.session.riskBreaches).toEqual([]);
  73. expect(responseData.session.orders).toEqual([]);
  74. } catch (error) {
  75. // This test should fail initially since the endpoint doesn't exist yet
  76. expect(error.response?.status).toBe(404);
  77. }
  78. });
  79. it('should reject request with invalid session name', async () => {
  80. const invalidRequest = {
  81. name: 'ab', // Too short (less than 3 characters)
  82. accountIds: ['account-1', 'account-2'],
  83. volumeTarget: 10000,
  84. strategy: {
  85. symbol: 'ETH/USD',
  86. volumeDistribution: 'equal',
  87. priceRange: { min: 0.001, max: 0.01 },
  88. timing: { minInterval: 30, maxInterval: 120, orderSize: { min: 100, max: 500 } },
  89. riskLimits: { maxPositionSize: 0.1, stopLossThreshold: 0.05, maxSlippage: 0.02 },
  90. orderTypes: { primary: 'limit', fallback: 'market' }
  91. }
  92. };
  93. try {
  94. await client.post('/sessions', invalidRequest);
  95. fail('Should have rejected invalid request');
  96. } catch (error) {
  97. expect(error.response?.status).toBe(400);
  98. expect(error.response?.data.success).toBe(false);
  99. expect(error.response?.data.error.code).toBe('INVALID_STRATEGY');
  100. }
  101. });
  102. it('should reject request with insufficient accounts', async () => {
  103. const invalidRequest = {
  104. name: 'Test Session',
  105. accountIds: ['account-1'], // Only one account (need at least 2)
  106. volumeTarget: 10000,
  107. strategy: {
  108. symbol: 'ETH/USD',
  109. volumeDistribution: 'equal',
  110. priceRange: { min: 0.001, max: 0.01 },
  111. timing: { minInterval: 30, maxInterval: 120, orderSize: { min: 100, max: 500 } },
  112. riskLimits: { maxPositionSize: 0.1, stopLossThreshold: 0.05, maxSlippage: 0.02 },
  113. orderTypes: { primary: 'limit', fallback: 'market' }
  114. }
  115. };
  116. try {
  117. await client.post('/sessions', invalidRequest);
  118. fail('Should have rejected invalid request');
  119. } catch (error) {
  120. expect(error.response?.status).toBe(400);
  121. expect(error.response?.data.success).toBe(false);
  122. expect(error.response?.data.error.code).toBe('INSUFFICIENT_ACCOUNTS');
  123. }
  124. });
  125. it('should reject request with negative volume target', async () => {
  126. const invalidRequest = {
  127. name: 'Test Session',
  128. accountIds: ['account-1', 'account-2'],
  129. volumeTarget: -1000, // Negative volume target
  130. strategy: {
  131. symbol: 'ETH/USD',
  132. volumeDistribution: 'equal',
  133. priceRange: { min: 0.001, max: 0.01 },
  134. timing: { minInterval: 30, maxInterval: 120, orderSize: { min: 100, max: 500 } },
  135. riskLimits: { maxPositionSize: 0.1, stopLossThreshold: 0.05, maxSlippage: 0.02 },
  136. orderTypes: { primary: 'limit', fallback: 'market' }
  137. }
  138. };
  139. try {
  140. await client.post('/sessions', invalidRequest);
  141. fail('Should have rejected invalid request');
  142. } catch (error) {
  143. expect(error.response?.status).toBe(400);
  144. expect(error.response?.data.success).toBe(false);
  145. expect(error.response?.data.error.code).toBe('INVALID_STRATEGY');
  146. }
  147. });
  148. it('should reject request with invalid strategy parameters', async () => {
  149. const invalidRequest = {
  150. name: 'Test Session',
  151. accountIds: ['account-1', 'account-2'],
  152. volumeTarget: 10000,
  153. strategy: {
  154. symbol: 'ETH/USD',
  155. volumeDistribution: 'equal',
  156. priceRange: {
  157. min: 0.01,
  158. max: 0.001 // min > max (invalid)
  159. },
  160. timing: { minInterval: 30, maxInterval: 120, orderSize: { min: 100, max: 500 } },
  161. riskLimits: { maxPositionSize: 0.1, stopLossThreshold: 0.05, maxSlippage: 0.02 },
  162. orderTypes: { primary: 'limit', fallback: 'market' }
  163. }
  164. };
  165. try {
  166. await client.post('/sessions', invalidRequest);
  167. fail('Should have rejected invalid request');
  168. } catch (error) {
  169. expect(error.response?.status).toBe(400);
  170. expect(error.response?.data.success).toBe(false);
  171. expect(error.response?.data.error.code).toBe('INVALID_STRATEGY');
  172. }
  173. });
  174. });
  175. describe('Authentication', () => {
  176. it('should reject request without authorization header', async () => {
  177. const clientWithoutAuth = axios.create({
  178. baseURL,
  179. headers: {
  180. 'Content-Type': 'application/json'
  181. }
  182. });
  183. const validRequest = {
  184. name: 'Test Session',
  185. accountIds: ['account-1', 'account-2'],
  186. volumeTarget: 10000,
  187. strategy: {
  188. symbol: 'ETH/USD',
  189. volumeDistribution: 'equal',
  190. priceRange: { min: 0.001, max: 0.01 },
  191. timing: { minInterval: 30, maxInterval: 120, orderSize: { min: 100, max: 500 } },
  192. riskLimits: { maxPositionSize: 0.1, stopLossThreshold: 0.05, maxSlippage: 0.02 },
  193. orderTypes: { primary: 'limit', fallback: 'market' }
  194. }
  195. };
  196. try {
  197. await clientWithoutAuth.post('/sessions', validRequest);
  198. fail('Should have rejected unauthorized request');
  199. } catch (error) {
  200. expect(error.response?.status).toBe(401);
  201. }
  202. });
  203. it('should reject request with invalid authorization token', async () => {
  204. const clientWithInvalidAuth = axios.create({
  205. baseURL,
  206. headers: {
  207. 'Content-Type': 'application/json',
  208. 'Authorization': 'Bearer invalid-token'
  209. }
  210. });
  211. const validRequest = {
  212. name: 'Test Session',
  213. accountIds: ['account-1', 'account-2'],
  214. volumeTarget: 10000,
  215. strategy: {
  216. symbol: 'ETH/USD',
  217. volumeDistribution: 'equal',
  218. priceRange: { min: 0.001, max: 0.01 },
  219. timing: { minInterval: 30, maxInterval: 120, orderSize: { min: 100, max: 500 } },
  220. riskLimits: { maxPositionSize: 0.1, stopLossThreshold: 0.05, maxSlippage: 0.02 },
  221. orderTypes: { primary: 'limit', fallback: 'market' }
  222. }
  223. };
  224. try {
  225. await clientWithInvalidAuth.post('/sessions', validRequest);
  226. fail('Should have rejected request with invalid token');
  227. } catch (error) {
  228. expect(error.response?.status).toBe(401);
  229. }
  230. });
  231. });
  232. describe('Business Logic Validation', () => {
  233. it('should reject request with inactive accounts', async () => {
  234. const requestWithInactiveAccount = {
  235. name: 'Test Session',
  236. accountIds: ['account-1', 'inactive-account'], // inactive-account is not active
  237. volumeTarget: 10000,
  238. strategy: {
  239. symbol: 'ETH/USD',
  240. volumeDistribution: 'equal',
  241. priceRange: { min: 0.001, max: 0.01 },
  242. timing: { minInterval: 30, maxInterval: 120, orderSize: { min: 100, max: 500 } },
  243. riskLimits: { maxPositionSize: 0.1, stopLossThreshold: 0.05, maxSlippage: 0.02 },
  244. orderTypes: { primary: 'limit', fallback: 'market' }
  245. }
  246. };
  247. try {
  248. await client.post('/sessions', requestWithInactiveAccount);
  249. fail('Should have rejected request with inactive account');
  250. } catch (error) {
  251. expect(error.response?.status).toBe(400);
  252. expect(error.response?.data.success).toBe(false);
  253. expect(error.response?.data.error.code).toBe('ACCOUNT_NOT_ACTIVE');
  254. }
  255. });
  256. it('should reject request with insufficient account balance', async () => {
  257. const requestWithInsufficientBalance = {
  258. name: 'Test Session',
  259. accountIds: ['account-1', 'poor-account'], // poor-account has insufficient balance
  260. volumeTarget: 1000000, // Very high volume target
  261. strategy: {
  262. symbol: 'ETH/USD',
  263. volumeDistribution: 'equal',
  264. priceRange: { min: 0.001, max: 0.01 },
  265. timing: { minInterval: 30, maxInterval: 120, orderSize: { min: 100, max: 500 } },
  266. riskLimits: { maxPositionSize: 0.1, stopLossThreshold: 0.05, maxSlippage: 0.02 },
  267. orderTypes: { primary: 'limit', fallback: 'market' }
  268. }
  269. };
  270. try {
  271. await client.post('/sessions', requestWithInsufficientBalance);
  272. fail('Should have rejected request with insufficient balance');
  273. } catch (error) {
  274. expect(error.response?.status).toBe(400);
  275. expect(error.response?.data.success).toBe(false);
  276. expect(error.response?.data.error.code).toBe('INSUFFICIENT_BALANCE');
  277. }
  278. });
  279. });
  280. });