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