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.

592 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.skip('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. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  115. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
  116. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  117. if (typeof resData === 'undefined') {
  118. expect.fail('Response body must be defined.');
  119. return;
  120. }
  121. expect(resData).toEqual([]);
  122. });
  123. });
  124. describe('serving collections', () => {
  125. beforeEach(() => {
  126. vi
  127. .spyOn(DummyDataSource.prototype, 'getMultiple')
  128. .mockResolvedValueOnce([] as never);
  129. });
  130. beforeEach(() => {
  131. Piano.canFetchCollection();
  132. });
  133. afterEach(() => {
  134. Piano.canFetchCollection(false);
  135. });
  136. it('returns data', async () => {
  137. const [res, resData] = await client({
  138. method: 'GET',
  139. path: `${BASE_PATH}/pianos`,
  140. });
  141. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  142. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
  143. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  144. if (typeof resData === 'undefined') {
  145. expect.fail('Response body must be defined.');
  146. return;
  147. }
  148. expect(resData).toEqual([]);
  149. });
  150. it('returns data on HEAD method', async () => {
  151. const [res] = await client({
  152. method: 'HEAD',
  153. path: `${BASE_PATH}/pianos`,
  154. });
  155. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  156. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
  157. });
  158. it('returns options', async () => {
  159. const [res] = await client({
  160. method: 'OPTIONS',
  161. path: `${BASE_PATH}/pianos`,
  162. });
  163. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  164. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  165. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  166. expect(allowedMethods).toContain('GET');
  167. expect(allowedMethods).toContain('HEAD');
  168. });
  169. });
  170. describe('serving items', () => {
  171. const existingResource = {
  172. id: 1,
  173. brand: 'Yamaha'
  174. };
  175. beforeEach(() => {
  176. vi
  177. .spyOn(DummyDataSource.prototype, 'getById')
  178. .mockResolvedValueOnce(existingResource as never);
  179. });
  180. beforeEach(() => {
  181. Piano.canFetchItem();
  182. });
  183. afterEach(() => {
  184. Piano.canFetchItem(false);
  185. });
  186. it('returns data', async () => {
  187. const [res, resData] = await client({
  188. method: 'GET',
  189. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  190. });
  191. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  192. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceFetched));
  193. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  194. if (typeof resData === 'undefined') {
  195. expect.fail('Response body must be defined.');
  196. return;
  197. }
  198. expect(resData).toEqual(existingResource);
  199. });
  200. it('returns data on HEAD method', async () => {
  201. const [res] = await client({
  202. method: 'HEAD',
  203. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  204. });
  205. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  206. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceFetched));
  207. });
  208. it('returns options', async () => {
  209. const [res] = await client({
  210. method: 'OPTIONS',
  211. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  212. });
  213. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  214. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  215. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  216. expect(allowedMethods).toContain('GET');
  217. expect(allowedMethods).toContain('HEAD');
  218. });
  219. });
  220. describe('creating items', () => {
  221. const newResourceData = {
  222. brand: 'K. Kawai'
  223. };
  224. const responseData = {
  225. id: 2,
  226. ...newResourceData,
  227. };
  228. beforeEach(() => {
  229. vi
  230. .spyOn(DummyDataSource.prototype, 'newId')
  231. .mockResolvedValueOnce(responseData.id as never);
  232. });
  233. beforeEach(() => {
  234. vi
  235. .spyOn(DummyDataSource.prototype, 'create')
  236. .mockResolvedValueOnce(responseData as never);
  237. });
  238. beforeEach(() => {
  239. Piano.canCreate();
  240. });
  241. afterEach(() => {
  242. Piano.canCreate(false);
  243. });
  244. it('returns data', async () => {
  245. const [res, resData] = await client({
  246. path: `${BASE_PATH}/pianos`,
  247. method: 'POST',
  248. body: newResourceData,
  249. });
  250. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  251. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCreated));
  252. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  253. expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/2`);
  254. if (typeof resData === 'undefined') {
  255. expect.fail('Response body must be defined.');
  256. return;
  257. }
  258. expect(resData).toEqual({
  259. ...newResourceData,
  260. id: 2
  261. });
  262. });
  263. it('returns options', async () => {
  264. const [res] = await client({
  265. method: 'OPTIONS',
  266. path: `${BASE_PATH}/pianos`,
  267. });
  268. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  269. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  270. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  271. expect(allowedMethods).toContain('POST');
  272. });
  273. });
  274. describe('patching items', () => {
  275. const existingResource = {
  276. id: 1,
  277. brand: 'Yamaha'
  278. };
  279. const patchData = {
  280. brand: 'K. Kawai'
  281. };
  282. beforeEach(() => {
  283. vi
  284. .spyOn(DummyDataSource.prototype, 'getById')
  285. .mockResolvedValueOnce(existingResource as never);
  286. });
  287. beforeEach(() => {
  288. vi
  289. .spyOn(DummyDataSource.prototype, 'patch')
  290. .mockResolvedValueOnce({
  291. ...existingResource,
  292. ...patchData,
  293. } as never);
  294. });
  295. beforeEach(() => {
  296. Piano.canPatch();
  297. });
  298. afterEach(() => {
  299. Piano.canPatch(false);
  300. });
  301. it('returns options', async () => {
  302. const [res] = await client({
  303. method: 'OPTIONS',
  304. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  305. });
  306. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  307. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  308. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  309. expect(allowedMethods).toContain('PATCH');
  310. const acceptPatch = res.headers['accept-patch']?.split(',').map((s) => s.trim()) ?? [];
  311. expect(acceptPatch).toContain('application/json-patch+json');
  312. expect(acceptPatch).toContain('application/merge-patch+json');
  313. });
  314. describe('on merge', () => {
  315. beforeEach(() => {
  316. Piano.canPatch(false).canPatch(['merge']);
  317. });
  318. it('returns data', async () => {
  319. const [res, resData] = await client({
  320. method: 'PATCH',
  321. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  322. body: patchData,
  323. headers: {
  324. 'content-type': 'application/merge-patch+json',
  325. },
  326. });
  327. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  328. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourcePatched));
  329. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  330. if (typeof resData === 'undefined') {
  331. expect.fail('Response body must be defined.');
  332. return;
  333. }
  334. expect(resData).toEqual({
  335. ...existingResource,
  336. ...patchData,
  337. });
  338. });
  339. });
  340. describe('on delta', () => {
  341. beforeEach(() => {
  342. Piano.canPatch(false).canPatch(['delta']);
  343. });
  344. it('returns data', async () => {
  345. const [res, resData] = await client({
  346. method: 'PATCH',
  347. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  348. body: [
  349. {
  350. op: 'replace',
  351. path: 'brand',
  352. value: patchData.brand,
  353. },
  354. ],
  355. headers: {
  356. 'content-type': 'application/json-patch+json',
  357. },
  358. });
  359. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  360. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourcePatched));
  361. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  362. if (typeof resData === 'undefined') {
  363. expect.fail('Response body must be defined.');
  364. return;
  365. }
  366. expect(resData).toEqual({
  367. ...existingResource,
  368. ...patchData,
  369. });
  370. });
  371. });
  372. });
  373. describe('emplacing items', () => {
  374. const existingResource = {
  375. id: 1,
  376. brand: 'Yamaha'
  377. };
  378. const emplaceResourceData = {
  379. id: 1,
  380. brand: 'K. Kawai'
  381. };
  382. beforeEach(() => {
  383. Piano.canEmplace();
  384. });
  385. afterEach(() => {
  386. Piano.canEmplace(false);
  387. });
  388. it('returns data for replacement', async () => {
  389. vi
  390. .spyOn(DummyDataSource.prototype, 'emplace')
  391. .mockResolvedValueOnce([{
  392. ...existingResource,
  393. ...emplaceResourceData,
  394. }, false] as never);
  395. const [res, resData] = await client({
  396. method: 'PUT',
  397. path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`,
  398. body: emplaceResourceData,
  399. });
  400. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  401. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceReplaced));
  402. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  403. if (typeof resData === 'undefined') {
  404. expect.fail('Response body must be defined.');
  405. return;
  406. }
  407. expect(resData).toEqual(emplaceResourceData);
  408. });
  409. it('returns data for creation', async () => {
  410. const newId = 2;
  411. vi
  412. .spyOn(DummyDataSource.prototype, 'emplace')
  413. .mockResolvedValueOnce([{
  414. ...existingResource,
  415. ...emplaceResourceData,
  416. id: newId
  417. }, true] as never);
  418. const [res, resData] = await client({
  419. method: 'PUT',
  420. path: `${BASE_PATH}/pianos/${newId}`,
  421. body: {
  422. ...emplaceResourceData,
  423. id: newId,
  424. },
  425. });
  426. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  427. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCreated));
  428. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  429. expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/${newId}`);
  430. if (typeof resData === 'undefined') {
  431. expect.fail('Response body must be defined.');
  432. return;
  433. }
  434. expect(resData).toEqual({
  435. ...emplaceResourceData,
  436. id: newId,
  437. });
  438. });
  439. it('returns options', async () => {
  440. const [res] = await client({
  441. method: 'OPTIONS',
  442. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  443. });
  444. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  445. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  446. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  447. expect(allowedMethods).toContain('PUT');
  448. });
  449. });
  450. describe('deleting items', () => {
  451. const existingResource = {
  452. id: 1,
  453. brand: 'Yamaha'
  454. };
  455. beforeEach(() => {
  456. vi
  457. .spyOn(DummyDataSource.prototype, 'getById')
  458. .mockResolvedValueOnce(existingResource as never);
  459. });
  460. beforeEach(() => {
  461. vi
  462. .spyOn(DummyDataSource.prototype, 'delete')
  463. .mockReturnValueOnce(Promise.resolve() as never);
  464. });
  465. beforeEach(() => {
  466. Piano.canDelete();
  467. });
  468. afterEach(() => {
  469. Piano.canDelete(false);
  470. });
  471. it('responds', async () => {
  472. const [res, resData] = await client({
  473. method: 'DELETE',
  474. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  475. });
  476. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  477. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceDeleted));
  478. expect(res.headers).not.toHaveProperty('content-type');
  479. expect(resData).toBeUndefined();
  480. });
  481. it('returns options', async () => {
  482. const [res] = await client({
  483. method: 'OPTIONS',
  484. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  485. });
  486. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  487. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  488. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  489. expect(allowedMethods).toContain('DELETE');
  490. });
  491. });
  492. });