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.

524 lines
13 KiB

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