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.

default.test.ts 13 KiB

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