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.

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