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.

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