HATEOAS-first backend framework.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

602 lines
15 KiB

  1. import {
  2. beforeAll,
  3. afterAll,
  4. afterEach,
  5. beforeEach,
  6. describe,
  7. expect,
  8. it,
  9. vi,
  10. } from 'vitest';
  11. import {constants} from 'http2';
  12. import {Backend, DataSource} from '../../../src/backend';
  13. import {
  14. application,
  15. resource,
  16. validation as v,
  17. Resource,
  18. Application,
  19. } from '../../../src/common';
  20. import {createTestClient, DummyDataSource, dummyGenerationStrategy, TEST_LANGUAGE, TestClient} from '../../utils';
  21. const PORT = 3000;
  22. const HOST = '127.0.0.1';
  23. const BASE_PATH = '/api';
  24. const ACCEPT = 'application/json';
  25. const ACCEPT_LANGUAGE = 'en';
  26. const ACCEPT_CHARSET = 'utf-8';
  27. const CONTENT_TYPE_CHARSET = 'utf-8';
  28. const CONTENT_TYPE = ACCEPT;
  29. const prepareStatusMessage = (s: string) => s.replace(/\$RESOURCE/g, 'Piano');
  30. describe('happy path', () => {
  31. let Piano: Resource;
  32. let app: Application;
  33. let dataSource: DataSource;
  34. let backend: Backend;
  35. let server: ReturnType<Backend['createHttpServer']>;
  36. let client: TestClient;
  37. beforeAll(() => {
  38. Piano = resource(v.object(
  39. {
  40. brand: v.string()
  41. },
  42. v.never()
  43. ))
  44. .name('Piano' as const)
  45. .route('pianos' as const)
  46. .id('id' as const, {
  47. generationStrategy: dummyGenerationStrategy,
  48. serialize: (id) => id?.toString() ?? '0',
  49. deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
  50. schema: v.number(),
  51. });
  52. app = application({
  53. name: 'piano-service',
  54. })
  55. .language(TEST_LANGUAGE)
  56. .resource(Piano);
  57. dataSource = new DummyDataSource();
  58. backend = app.createBackend({
  59. dataSource,
  60. });
  61. server = backend.createHttpServer({
  62. basePath: BASE_PATH
  63. });
  64. client = createTestClient({
  65. host: HOST,
  66. port: PORT,
  67. })
  68. .acceptMediaType(ACCEPT)
  69. .acceptLanguage(ACCEPT_LANGUAGE)
  70. .acceptCharset(ACCEPT_CHARSET)
  71. .contentType(CONTENT_TYPE)
  72. .contentCharset(CONTENT_TYPE_CHARSET);
  73. return new Promise((resolve, reject) => {
  74. server.on('error', (err) => {
  75. reject(err);
  76. });
  77. server.on('listening', () => {
  78. resolve();
  79. });
  80. server.listen({
  81. port: PORT
  82. });
  83. });
  84. });
  85. afterAll(() => new Promise<void>((resolve, reject) => {
  86. server.close((err) => {
  87. if (err) {
  88. reject(err);
  89. }
  90. resolve();
  91. });
  92. }));
  93. describe('querying collections', () => {
  94. beforeEach(() => {
  95. vi
  96. .spyOn(DummyDataSource.prototype, 'getMultiple')
  97. .mockResolvedValueOnce([] as never);
  98. });
  99. beforeEach(() => {
  100. Piano.canFetchCollection();
  101. });
  102. afterEach(() => {
  103. Piano.canFetchCollection(false);
  104. });
  105. it('returns data', async () => {
  106. // const [res, resData] = await client({
  107. // method: 'QUERY',
  108. // path: `${BASE_PATH}/pianos`,
  109. // headers: {
  110. // 'content-type': 'application/x-www-form-urlencoded',
  111. // },
  112. // body: 'foo=bar',
  113. // });
  114. const [res, resData] = await client({
  115. method: 'POST',
  116. path: `${BASE_PATH}/pianos`,
  117. headers: {
  118. 'content-type': 'application/x-www-form-urlencoded',
  119. 'x-original-method': 'QUERY',
  120. },
  121. body: 'foo=bar',
  122. });
  123. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  124. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
  125. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  126. if (typeof resData === 'undefined') {
  127. expect.fail('Response body must be defined.');
  128. return;
  129. }
  130. expect(resData).toEqual([]);
  131. });
  132. });
  133. describe('serving collections', () => {
  134. beforeEach(() => {
  135. vi
  136. .spyOn(DummyDataSource.prototype, 'getMultiple')
  137. .mockResolvedValueOnce([] as never);
  138. });
  139. beforeEach(() => {
  140. Piano.canFetchCollection();
  141. });
  142. afterEach(() => {
  143. Piano.canFetchCollection(false);
  144. });
  145. it('returns data', async () => {
  146. const [res, resData] = await client({
  147. method: 'GET',
  148. path: `${BASE_PATH}/pianos`,
  149. });
  150. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  151. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
  152. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  153. if (typeof resData === 'undefined') {
  154. expect.fail('Response body must be defined.');
  155. return;
  156. }
  157. expect(resData).toEqual([]);
  158. });
  159. it('returns data on HEAD method', async () => {
  160. const [res] = await client({
  161. method: 'HEAD',
  162. path: `${BASE_PATH}/pianos`,
  163. });
  164. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  165. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
  166. });
  167. it('returns options', async () => {
  168. const [res] = await client({
  169. method: 'OPTIONS',
  170. path: `${BASE_PATH}/pianos`,
  171. });
  172. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  173. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  174. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  175. expect(allowedMethods).toContain('GET');
  176. expect(allowedMethods).toContain('HEAD');
  177. });
  178. });
  179. describe('serving items', () => {
  180. const existingResource = {
  181. id: 1,
  182. brand: 'Yamaha'
  183. };
  184. beforeEach(() => {
  185. vi
  186. .spyOn(DummyDataSource.prototype, 'getById')
  187. .mockResolvedValueOnce(existingResource as never);
  188. });
  189. beforeEach(() => {
  190. Piano.canFetchItem();
  191. });
  192. afterEach(() => {
  193. Piano.canFetchItem(false);
  194. });
  195. it('returns data', async () => {
  196. const [res, resData] = await client({
  197. method: 'GET',
  198. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  199. });
  200. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  201. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceFetched));
  202. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  203. if (typeof resData === 'undefined') {
  204. expect.fail('Response body must be defined.');
  205. return;
  206. }
  207. expect(resData).toEqual(existingResource);
  208. });
  209. it('returns data on HEAD method', async () => {
  210. const [res] = await client({
  211. method: 'HEAD',
  212. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  213. });
  214. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  215. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceFetched));
  216. });
  217. it('returns options', async () => {
  218. const [res] = await client({
  219. method: 'OPTIONS',
  220. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  221. });
  222. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  223. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  224. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  225. expect(allowedMethods).toContain('GET');
  226. expect(allowedMethods).toContain('HEAD');
  227. });
  228. });
  229. describe('creating items', () => {
  230. const newResourceData = {
  231. brand: 'K. Kawai'
  232. };
  233. const responseData = {
  234. id: 2,
  235. ...newResourceData,
  236. };
  237. beforeEach(() => {
  238. vi
  239. .spyOn(DummyDataSource.prototype, 'newId')
  240. .mockResolvedValueOnce(responseData.id as never);
  241. });
  242. beforeEach(() => {
  243. vi
  244. .spyOn(DummyDataSource.prototype, 'create')
  245. .mockResolvedValueOnce(responseData as never);
  246. });
  247. beforeEach(() => {
  248. Piano.canCreate();
  249. });
  250. afterEach(() => {
  251. Piano.canCreate(false);
  252. });
  253. it('returns data', async () => {
  254. const [res, resData] = await client({
  255. path: `${BASE_PATH}/pianos`,
  256. method: 'POST',
  257. body: newResourceData,
  258. });
  259. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  260. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCreated));
  261. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  262. expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/2`);
  263. if (typeof resData === 'undefined') {
  264. expect.fail('Response body must be defined.');
  265. return;
  266. }
  267. expect(resData).toEqual({
  268. ...newResourceData,
  269. id: 2
  270. });
  271. });
  272. it('returns options', async () => {
  273. const [res] = await client({
  274. method: 'OPTIONS',
  275. path: `${BASE_PATH}/pianos`,
  276. });
  277. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  278. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  279. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  280. expect(allowedMethods).toContain('POST');
  281. });
  282. });
  283. describe('patching items', () => {
  284. const existingResource = {
  285. id: 1,
  286. brand: 'Yamaha'
  287. };
  288. const patchData = {
  289. brand: 'K. Kawai'
  290. };
  291. beforeEach(() => {
  292. vi
  293. .spyOn(DummyDataSource.prototype, 'getById')
  294. .mockResolvedValueOnce(existingResource as never);
  295. });
  296. beforeEach(() => {
  297. vi
  298. .spyOn(DummyDataSource.prototype, 'patch')
  299. .mockResolvedValueOnce({
  300. ...existingResource,
  301. ...patchData,
  302. } as never);
  303. });
  304. beforeEach(() => {
  305. Piano.canPatch();
  306. });
  307. afterEach(() => {
  308. Piano.canPatch(false);
  309. });
  310. it('returns options', async () => {
  311. const [res] = await client({
  312. method: 'OPTIONS',
  313. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  314. });
  315. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  316. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  317. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  318. expect(allowedMethods).toContain('PATCH');
  319. const acceptPatch = res.headers['accept-patch']?.split(',').map((s) => s.trim()) ?? [];
  320. expect(acceptPatch).toContain('application/json-patch+json');
  321. expect(acceptPatch).toContain('application/merge-patch+json');
  322. });
  323. describe('on merge', () => {
  324. beforeEach(() => {
  325. Piano.canPatch(false).canPatch(['merge']);
  326. });
  327. it('returns data', async () => {
  328. const [res, resData] = await client({
  329. method: 'PATCH',
  330. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  331. body: patchData,
  332. headers: {
  333. 'content-type': 'application/merge-patch+json',
  334. },
  335. });
  336. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  337. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourcePatched));
  338. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  339. if (typeof resData === 'undefined') {
  340. expect.fail('Response body must be defined.');
  341. return;
  342. }
  343. expect(resData).toEqual({
  344. ...existingResource,
  345. ...patchData,
  346. });
  347. });
  348. });
  349. describe('on delta', () => {
  350. beforeEach(() => {
  351. Piano.canPatch(false).canPatch(['delta']);
  352. });
  353. it('returns data', async () => {
  354. const [res, resData] = await client({
  355. method: 'PATCH',
  356. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  357. body: [
  358. {
  359. op: 'replace',
  360. path: 'brand',
  361. value: patchData.brand,
  362. },
  363. ],
  364. headers: {
  365. 'content-type': 'application/json-patch+json',
  366. },
  367. });
  368. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  369. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourcePatched));
  370. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  371. if (typeof resData === 'undefined') {
  372. expect.fail('Response body must be defined.');
  373. return;
  374. }
  375. expect(resData).toEqual({
  376. ...existingResource,
  377. ...patchData,
  378. });
  379. });
  380. });
  381. });
  382. describe('emplacing items', () => {
  383. const existingResource = {
  384. id: 1,
  385. brand: 'Yamaha'
  386. };
  387. const emplaceResourceData = {
  388. id: 1,
  389. brand: 'K. Kawai'
  390. };
  391. beforeEach(() => {
  392. Piano.canEmplace();
  393. });
  394. afterEach(() => {
  395. Piano.canEmplace(false);
  396. });
  397. it('returns data for replacement', async () => {
  398. vi
  399. .spyOn(DummyDataSource.prototype, 'emplace')
  400. .mockResolvedValueOnce([{
  401. ...existingResource,
  402. ...emplaceResourceData,
  403. }, false] as never);
  404. const [res, resData] = await client({
  405. method: 'PUT',
  406. path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`,
  407. body: emplaceResourceData,
  408. });
  409. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  410. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceReplaced));
  411. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  412. if (typeof resData === 'undefined') {
  413. expect.fail('Response body must be defined.');
  414. return;
  415. }
  416. expect(resData).toEqual(emplaceResourceData);
  417. });
  418. it('returns data for creation', async () => {
  419. const newId = 2;
  420. vi
  421. .spyOn(DummyDataSource.prototype, 'emplace')
  422. .mockResolvedValueOnce([{
  423. ...existingResource,
  424. ...emplaceResourceData,
  425. id: newId
  426. }, true] as never);
  427. const [res, resData] = await client({
  428. method: 'PUT',
  429. path: `${BASE_PATH}/pianos/${newId}`,
  430. body: {
  431. ...emplaceResourceData,
  432. id: newId,
  433. },
  434. });
  435. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  436. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCreated));
  437. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  438. expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/${newId}`);
  439. if (typeof resData === 'undefined') {
  440. expect.fail('Response body must be defined.');
  441. return;
  442. }
  443. expect(resData).toEqual({
  444. ...emplaceResourceData,
  445. id: newId,
  446. });
  447. });
  448. it('returns options', async () => {
  449. const [res] = await client({
  450. method: 'OPTIONS',
  451. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  452. });
  453. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  454. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  455. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  456. expect(allowedMethods).toContain('PUT');
  457. });
  458. });
  459. describe('deleting items', () => {
  460. const existingResource = {
  461. id: 1,
  462. brand: 'Yamaha'
  463. };
  464. beforeEach(() => {
  465. vi
  466. .spyOn(DummyDataSource.prototype, 'getById')
  467. .mockResolvedValueOnce(existingResource as never);
  468. });
  469. beforeEach(() => {
  470. vi
  471. .spyOn(DummyDataSource.prototype, 'delete')
  472. .mockReturnValueOnce(Promise.resolve() as never);
  473. });
  474. beforeEach(() => {
  475. Piano.canDelete();
  476. });
  477. afterEach(() => {
  478. Piano.canDelete(false);
  479. });
  480. it('responds', async () => {
  481. const [res, resData] = await client({
  482. method: 'DELETE',
  483. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  484. });
  485. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  486. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceDeleted));
  487. expect(res.headers).not.toHaveProperty('content-type');
  488. expect(resData).toBeUndefined();
  489. });
  490. it('returns options', async () => {
  491. const [res] = await client({
  492. method: 'OPTIONS',
  493. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  494. });
  495. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  496. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  497. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  498. expect(allowedMethods).toContain('DELETE');
  499. });
  500. });
  501. });