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.

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