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.

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