diff --git a/package-lock.json b/package-lock.json index 86262db..4190709 100644 --- a/package-lock.json +++ b/package-lock.json @@ -203,12 +203,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.15.tgz", - "integrity": "sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dev": true, "dependencies": { - "@babel/types": "^7.22.15", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -252,13 +252,13 @@ } }, "node_modules/@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "dependencies": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" @@ -458,9 +458,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.22.16", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.16.tgz", - "integrity": "sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -673,19 +673,19 @@ } }, "node_modules/@babel/traverse": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.20.tgz", - "integrity": "sha512-eU260mPZbU7mZ0N+X10pxXhQFMGTeLb9eFS0mxehS8HZp9o1uSnFeWQuG1UPrlxgA7QoUzFhOnilHDp0AXCyHw==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", "dev": true, "dependencies": { "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.22.15", + "@babel/generator": "^7.23.0", "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.22.5", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.16", - "@babel/types": "^7.22.19", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -703,13 +703,13 @@ } }, "node_modules/@babel/types": { - "version": "7.22.19", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.19.tgz", - "integrity": "sha512-P7LAw/LbojPzkgp5oznjE6tQEIWbp4PkkfrZDINTro9zgBRtI324/EYsiSI7lhPbpIQ+DCeR2NNmMWANGGfZsg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.19", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { diff --git a/src/modules/item/__test__/item.cache.test.ts b/src/modules/item/__test__/item.cache.test.ts new file mode 100644 index 0000000..f79f4bb --- /dev/null +++ b/src/modules/item/__test__/item.cache.test.ts @@ -0,0 +1,985 @@ +import { User } from '@prisma/client'; +import UserService from '../../auth/user.service'; +import FolderService from '../folder/folder.service'; +import AuthService from '../../auth/auth.service'; +import BlobService from '../blob/blob.service'; +import DocsService from '../docs/docs.service'; +import ShortcutService from '../shortcut/shortcut.service'; +import SharingService from '../sharing/sharing.service'; +import { AuthServiceFactory, UserServiceFactory } from '../../auth/auth.factory'; +import { FolderServiceFactory } from '../folder/folder.factory'; +import { BlobServiceFactory } from '../blob/blob.factory'; +import { DocsServiceFactory } from '../docs/docs.factory'; +import { ShortcutServiceFactory } from '../shortcut/shortcut.factory'; +import { SharingServiceFactory } from '../sharing/sharing.factory'; +import { Folder } from '../folder/folder.schema'; + +describe('GET /api/item with caching', () => { + let userService: UserService; + let authService: AuthService; + let folderService: FolderService; + let blobService: BlobService; + let docsService: DocsService; + let shortcutService: ShortcutService; + + let user: User; + + beforeAll(async () => { + userService = UserServiceFactory.make(); + folderService = FolderServiceFactory.make(); + authService = AuthServiceFactory.make(); + blobService = BlobServiceFactory.make(); + docsService = DocsServiceFactory.make(); + shortcutService = ShortcutServiceFactory.make(); + + user = await userService.createUser({ + name: 'Joe Biden the 1st', + email: 'joe@biden.com', + password: '1234', + }); + }); + + it('Should return status 200 and all items from root folder, even when created, updated and deleted', async () => { + const { accessToken } = await authService.createTokens(user.id); + + /** + * + * Empty + * + */ + const emptyResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(emptyResponse.statusCode).toBe(200); + expect(emptyResponse.json()).toEqual([]); + + /** + * + * Folder + * + */ + const createdFolder = await folderService.createFolder({ + name: 'Folder1', + color: '#123456', + ownerId: user.id, + parentId: null, + }); + + const createdFolderResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdFolderResponse.statusCode).toBe(200); + expect(createdFolderResponse.json()).toEqual([ + { + id: createdFolder.id, + name: createdFolder.name, + color: createdFolder.color, + parentId: createdFolder.parentId, + ownerId: createdFolder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedFolder = await folderService.updateFolder({ + id: createdFolder.id, + color: '#987654', + name: 'Folder1', + ownerId: user.id, + parentId: null, + }); + + const updatedFolderResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedFolderResponse.statusCode).toBe(200); + expect(updatedFolderResponse.json()).toEqual([ + { + id: updatedFolder.id, + name: updatedFolder.name, + color: updatedFolder.color, + parentId: updatedFolder.parentId, + ownerId: updatedFolder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await folderService.deleteFolderByItemId(createdFolder.id); + + const deletedFolderResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedFolderResponse.statusCode).toBe(200); + expect(deletedFolderResponse.json()).toEqual([]); + + /** + * + * Blob + * + */ + const createdBlob = await blobService.createBlob({ + name: 'test1.txt', + blobUrl: 'https://example.com/test1.txt', + mimeType: 'text/plain', + ownerId: user.id, + parentId: null, + }); + + const createdBlobResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdBlobResponse.statusCode).toBe(200); + expect(createdBlobResponse.json()).toEqual([ + { + id: createdBlob.id, + name: createdBlob.name, + blobUrl: createdBlob.blobUrl, + parentId: createdBlob.parentId, + ownerId: createdBlob.ownerId, + mimeType: createdBlob.mimeType, + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedBlob = await blobService.updateBlob({ + id: createdBlob.id, + name: 'test2.txt', + blobUrl: 'https://example.com/test2.txt', + mimeType: 'text/plain', + ownerId: user.id, + parentId: null, + }); + + const updatedBlobResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedBlobResponse.statusCode).toBe(200); + expect(updatedBlobResponse.json()).toEqual([ + { + id: updatedBlob.id, + name: updatedBlob.name, + blobUrl: updatedBlob.blobUrl, + parentId: updatedBlob.parentId, + ownerId: updatedBlob.ownerId, + mimeType: updatedBlob.mimeType, + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await blobService.deleteBlobByItemId(createdBlob.id); + + const deletedBlobResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedBlobResponse.statusCode).toBe(200); + expect(deletedBlobResponse.json()).toEqual([]); + + /** + * + * Docs + * + */ + const createdDocs = await docsService.createDocs({ + name: 'Docs1', + text: 'Docs1 text', + ownerId: user.id, + parentId: null, + }); + + const createdDocsResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdDocsResponse.statusCode).toBe(200); + expect(createdDocsResponse.json()).toEqual([ + { + id: createdDocs.id, + name: createdDocs.name, + text: createdDocs.text, + parentId: createdDocs.parentId, + ownerId: createdDocs.ownerId, + mimeType: 'application/vnd.cloudstore.docs', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedDocs = await docsService.updateDocs({ + id: createdDocs.id, + name: 'Docs2', + text: 'Docs2 text', + ownerId: user.id, + parentId: null, + }); + + const updatedDocsResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedDocsResponse.statusCode).toBe(200); + expect(updatedDocsResponse.json()).toEqual([ + { + id: updatedDocs.id, + name: updatedDocs.name, + text: updatedDocs.text, + parentId: updatedDocs.parentId, + ownerId: updatedDocs.ownerId, + mimeType: 'application/vnd.cloudstore.docs', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await docsService.deleteDocsByItemId(createdDocs.id); + + const deletedDocsResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedDocsResponse.statusCode).toBe(200); + expect(deletedDocsResponse.json()).toEqual([]); + + /** + * + * Shortcut + * + */ + const linkedFolder = await folderService.createFolder({ + name: 'Linked Folder', + color: '#123456', + ownerId: user.id, + parentId: null, + }); + + // Set cache to make sure that shortcut invalidates cache on created. + await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + const createdShortcut = await shortcutService.createShortcut({ + name: 'Shortcut', + ownerId: user.id, + linkedItemId: linkedFolder.id, + parentId: null, + }); + + const createdShortcutResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdShortcutResponse.statusCode).toBe(200); + expect(createdShortcutResponse.json()).toEqual([ + { + id: linkedFolder.id, + name: linkedFolder.name, + parentId: linkedFolder.parentId, + ownerId: linkedFolder.ownerId, + color: linkedFolder.color, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + { + id: createdShortcut.id, + name: createdShortcut.name, + parentId: createdShortcut.parentId, + ownerId: createdShortcut.ownerId, + linkedItemId: createdShortcut.linkedItemId, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedShortcut = await shortcutService.updateShortcut({ + id: createdShortcut.id, + name: 'Shortcut2', + ownerId: user.id, + linkedItemId: linkedFolder.id, + parentId: null, + }); + + const updatedShortcutResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedShortcutResponse.statusCode).toBe(200); + expect(updatedShortcutResponse.json()).toEqual([ + { + id: linkedFolder.id, + name: linkedFolder.name, + parentId: linkedFolder.parentId, + ownerId: linkedFolder.ownerId, + color: linkedFolder.color, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + { + id: updatedShortcut.id, + name: updatedShortcut.name, + parentId: updatedShortcut.parentId, + ownerId: updatedShortcut.ownerId, + linkedItemId: updatedShortcut.linkedItemId, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await shortcutService.deleteShortcutByItemId(createdShortcut.id); + + const deletedShortcutResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedShortcutResponse.statusCode).toBe(200); + expect(deletedShortcutResponse.json()).toEqual([ + { + id: linkedFolder.id, + name: linkedFolder.name, + parentId: linkedFolder.parentId, + ownerId: linkedFolder.ownerId, + color: linkedFolder.color, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + }); +}); + +describe('GET /api/item/:parentId with caching', () => { + let userService: UserService; + let authService: AuthService; + let folderService: FolderService; + let blobService: BlobService; + let docsService: DocsService; + let shortcutService: ShortcutService; + + let sharingService: SharingService; + + let user: User; + let parentFolder: Folder; + let accessToken: string; + + beforeAll(async () => { + userService = UserServiceFactory.make(); + folderService = FolderServiceFactory.make(); + authService = AuthServiceFactory.make(); + blobService = BlobServiceFactory.make(); + docsService = DocsServiceFactory.make(); + shortcutService = ShortcutServiceFactory.make(); + + sharingService = SharingServiceFactory.make(); + + user = await userService.createUser({ + name: 'Joe Biden the 1st', + email: 'joe45435346354@biden.com', + password: '1234', + }); + + parentFolder = await folderService.createFolder({ + name: 'Test Folder', + color: '#123456', + ownerId: user.id, + parentId: null, + }); + + accessToken = (await authService.createTokens(user.id)).accessToken; + }); + + it('Should return status 200 and all items from folder, even when created, updated and deleted', async () => { + /** + * + * Empty + * + */ + const emptyResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(emptyResponse.statusCode).toBe(200); + expect(emptyResponse.json()).toEqual([]); + + /** + * + * Folder + * + */ + const createdFolder = await folderService.createFolder({ + name: 'Folder1', + color: '#123456', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const createdFolderResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdFolderResponse.statusCode).toBe(200); + expect(createdFolderResponse.json()).toEqual([ + { + id: createdFolder.id, + name: createdFolder.name, + color: createdFolder.color, + parentId: createdFolder.parentId, + ownerId: createdFolder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedFolder = await folderService.updateFolder({ + id: createdFolder.id, + color: '#987654', + name: 'Folder1', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const updatedFolderResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedFolderResponse.statusCode).toBe(200); + expect(updatedFolderResponse.json()).toEqual([ + { + id: updatedFolder.id, + name: updatedFolder.name, + color: updatedFolder.color, + parentId: updatedFolder.parentId, + ownerId: updatedFolder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await folderService.deleteFolderByItemId(createdFolder.id); + + const deletedFolderResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedFolderResponse.statusCode).toBe(200); + expect(deletedFolderResponse.json()).toEqual([]); + + /** + * + * Blob + * + */ + const createdBlob = await blobService.createBlob({ + name: 'test1.txt', + blobUrl: 'https://example.com/test1.txt', + mimeType: 'text/plain', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const createdBlobResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdBlobResponse.statusCode).toBe(200); + expect(createdBlobResponse.json()).toEqual([ + { + id: createdBlob.id, + name: createdBlob.name, + blobUrl: createdBlob.blobUrl, + parentId: createdBlob.parentId, + ownerId: createdBlob.ownerId, + mimeType: createdBlob.mimeType, + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedBlob = await blobService.updateBlob({ + id: createdBlob.id, + name: 'test2.txt', + blobUrl: 'https://example.com/test2.txt', + mimeType: 'text/plain', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const updatedBlobResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedBlobResponse.statusCode).toBe(200); + expect(updatedBlobResponse.json()).toEqual([ + { + id: updatedBlob.id, + name: updatedBlob.name, + blobUrl: updatedBlob.blobUrl, + parentId: updatedBlob.parentId, + ownerId: updatedBlob.ownerId, + mimeType: updatedBlob.mimeType, + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await blobService.deleteBlobByItemId(createdBlob.id); + + const deletedBlobResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedBlobResponse.statusCode).toBe(200); + expect(deletedBlobResponse.json()).toEqual([]); + + /** + * + * Docs + * + */ + const createdDocs = await docsService.createDocs({ + name: 'Docs1', + text: 'Docs1 text', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const createdDocsResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdDocsResponse.statusCode).toBe(200); + expect(createdDocsResponse.json()).toEqual([ + { + id: createdDocs.id, + name: createdDocs.name, + text: createdDocs.text, + parentId: createdDocs.parentId, + ownerId: createdDocs.ownerId, + mimeType: 'application/vnd.cloudstore.docs', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedDocs = await docsService.updateDocs({ + id: createdDocs.id, + name: 'Docs2', + text: 'Docs2 text', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const updatedDocsResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedDocsResponse.statusCode).toBe(200); + expect(updatedDocsResponse.json()).toEqual([ + { + id: updatedDocs.id, + name: updatedDocs.name, + text: updatedDocs.text, + parentId: updatedDocs.parentId, + ownerId: updatedDocs.ownerId, + mimeType: 'application/vnd.cloudstore.docs', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await docsService.deleteDocsByItemId(createdDocs.id); + + const deletedDocsResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedDocsResponse.statusCode).toBe(200); + expect(deletedDocsResponse.json()).toEqual([]); + + /** + * + * Shortcut + * + */ + const linkedFolder = await folderService.createFolder({ + name: 'Linked Folder', + color: '#123456', + ownerId: user.id, + parentId: parentFolder.id, + }); + + // Set cache to make sure that shortcut invalidates cache on created. + await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + const createdShortcut = await shortcutService.createShortcut({ + name: 'Shortcut', + ownerId: user.id, + linkedItemId: linkedFolder.id, + parentId: parentFolder.id, + }); + + const createdShortcutResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(createdShortcutResponse.statusCode).toBe(200); + expect(createdShortcutResponse.json()).toEqual([ + { + id: linkedFolder.id, + name: linkedFolder.name, + parentId: linkedFolder.parentId, + ownerId: linkedFolder.ownerId, + color: linkedFolder.color, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + { + id: createdShortcut.id, + name: createdShortcut.name, + parentId: createdShortcut.parentId, + ownerId: createdShortcut.ownerId, + linkedItemId: createdShortcut.linkedItemId, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + const updatedShortcut = await shortcutService.updateShortcut({ + id: createdShortcut.id, + name: 'Shortcut2', + ownerId: user.id, + linkedItemId: linkedFolder.id, + parentId: parentFolder.id, + }); + + const updatedShortcutResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(updatedShortcutResponse.statusCode).toBe(200); + expect(updatedShortcutResponse.json()).toEqual([ + { + id: linkedFolder.id, + name: linkedFolder.name, + parentId: linkedFolder.parentId, + ownerId: linkedFolder.ownerId, + color: linkedFolder.color, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + { + id: updatedShortcut.id, + name: updatedShortcut.name, + parentId: updatedShortcut.parentId, + ownerId: updatedShortcut.ownerId, + linkedItemId: updatedShortcut.linkedItemId, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await shortcutService.deleteShortcutByItemId(createdShortcut.id); + + const deletedShortcutResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(deletedShortcutResponse.statusCode).toBe(200); + expect(deletedShortcutResponse.json()).toEqual([ + { + id: linkedFolder.id, + name: linkedFolder.name, + parentId: linkedFolder.parentId, + ownerId: linkedFolder.ownerId, + color: linkedFolder.color, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + await folderService.deleteFolderByItemId(linkedFolder.id); + }); + + it('Should clear cache when shared', async () => { + const folder = await folderService.createFolder({ + name: 'Folder1', + color: '#123456', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const otherUser = await userService.createUser({ + name: 'Joe Biden the 2nd', + email: 'share@with.me', + password: '1234', + }); + + const otherAccessToken = (await authService.createTokens(otherUser.id)).accessToken; + + // Hasn't been shared + const emptyResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + otherAccessToken, + }, + }); + + expect(emptyResponse.statusCode).toBe(401); + + // Share folder + await sharingService.createSharing( + { + itemId: parentFolder.id, + userId: otherUser.id, + }, + user.id, + ); + + const sharedResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + otherAccessToken, + }, + }); + + expect(sharedResponse.statusCode).toBe(200); + expect(sharedResponse.json()).toEqual([ + { + id: folder.id, + name: folder.name, + color: folder.color, + parentId: folder.parentId, + ownerId: folder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + + // Add folder to shared folder + const anotherFolderInShared = await folderService.createFolder({ + name: 'Folder1', + color: '#123456', + ownerId: user.id, + parentId: parentFolder.id, + }); + + const sharedAnotherResponse = await global.fastify.inject({ + method: 'GET', + url: '/api/item/' + parentFolder.id, + headers: { + authorization: 'Bearer ' + otherAccessToken, + }, + }); + + expect(sharedAnotherResponse.statusCode).toBe(200); + expect(sharedAnotherResponse.json()).toEqual([ + { + id: folder.id, + name: folder.name, + color: folder.color, + parentId: folder.parentId, + ownerId: folder.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + { + id: anotherFolderInShared.id, + name: anotherFolderInShared.name, + color: anotherFolderInShared.color, + parentId: anotherFolderInShared.parentId, + ownerId: anotherFolderInShared.ownerId, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: false, + }, + ]); + }); +}); diff --git a/src/modules/item/blob/blob.controller.ts b/src/modules/item/blob/blob.controller.ts index 916397f..d850e20 100644 --- a/src/modules/item/blob/blob.controller.ts +++ b/src/modules/item/blob/blob.controller.ts @@ -23,7 +23,7 @@ export default class BlobController { try { const blob = await this.blobService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(blob.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(blob, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -42,7 +42,7 @@ export default class BlobController { try { const blob = await this.blobService.getByItemId(request.body.id); - if (!(await this.accessService.hasAccessToItem(blob.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(blob, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -146,7 +146,7 @@ export default class BlobController { if ( clientPayloadObject.parentId !== null && - !(await this.accessService.hasAccessToItem( + !(await this.accessService.hasAccessToItemId( clientPayloadObject.parentId, accessTokenPayload.sub, )) @@ -176,7 +176,7 @@ export default class BlobController { try { const blob = await this.blobService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(blob.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(blob, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/blob/blob.service.ts b/src/modules/item/blob/blob.service.ts index 1358cf8..148d3ed 100644 --- a/src/modules/item/blob/blob.service.ts +++ b/src/modules/item/blob/blob.service.ts @@ -6,6 +6,7 @@ import { prisma } from '../../../plugins/prisma'; import { Blob, CreateBlob, UpdateBlob, ItemBlob } from './blob.schema'; import SharingService from '../sharing/sharing.service'; import { MissingError, UnauthorizedError } from '../../../utils/error'; +import ItemService from '../item.service'; type OnUploadCompletedCallback = (body: { blob: PutBlobResult; @@ -80,7 +81,11 @@ export default class BlobService { await this.sharingService.syncSharingsByItemId(input.parentId, itemBlob.item.id); } - return this.formatItemBlob(itemBlob); + const blob = this.formatItemBlob(itemBlob); + + await ItemService.invalidateCachesForItem(blob); + + return blob; } public async updateBlob(input: UpdateBlob): Promise { @@ -101,29 +106,43 @@ export default class BlobService { }, }); - return this.formatItemBlob(itemBlob); + const blob = this.formatItemBlob(itemBlob); + + await ItemService.invalidateCachesForItem(blob); + + return blob; } public async deleteBlobByItemId(itemId: number): Promise { - const itemBlob = await prisma.item.delete({ - where: { - id: itemId, - }, - include: { - ItemBlob: true, - }, - }); - - /* istanbul ignore next */ - if (!itemBlob.ItemBlob) { - return; - } + let blob: Blob; try { - await this.deleteBlobByUrl(itemBlob.ItemBlob.blobUrl); + blob = await this.getByItemId(itemId); } catch (e) { - // Do nothing + /* istanbul ignore next */ + return; } + + await Promise.all([ + prisma.item.delete({ + where: { + id: itemId, + }, + }), + ItemService.invalidateCachesForItem(blob), + (async () => { + /* istanbul ignore next */ + if (!blob.blobUrl) { + return; + } + + try { + await this.deleteBlobByUrl(blob.blobUrl); + } catch (e) { + // Do nothing + } + })(), + ]); } public async deleteBlobByUrl(url: string | string[]): Promise { diff --git a/src/modules/item/docs/docs.controller.ts b/src/modules/item/docs/docs.controller.ts index 7f7f866..2e1d1b9 100644 --- a/src/modules/item/docs/docs.controller.ts +++ b/src/modules/item/docs/docs.controller.ts @@ -23,7 +23,7 @@ export default class DocsController { try { const docs = await this.docsService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(docs.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(docs, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -42,7 +42,7 @@ export default class DocsController { try { const docs = await this.docsService.getByItemId(request.body.id); - if (!(await this.accessService.hasAccessToItem(docs.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(docs, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -66,7 +66,7 @@ export default class DocsController { if ( request.body.parentId !== null && request.body.parentId !== undefined && - !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + !(await this.accessService.hasAccessToItemId(request.body.parentId, request.user.sub)) ) { throw new UnauthorizedError('error.unauthorized'); } @@ -95,7 +95,7 @@ export default class DocsController { try { const docs = await this.docsService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(docs.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(docs, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/docs/docs.service.ts b/src/modules/item/docs/docs.service.ts index 9b5659f..9f57f8c 100644 --- a/src/modules/item/docs/docs.service.ts +++ b/src/modules/item/docs/docs.service.ts @@ -1,5 +1,6 @@ import { prisma } from '../../../plugins/prisma'; import { MissingError } from '../../../utils/error'; +import ItemService from '../item.service'; import SharingService from '../sharing/sharing.service'; import { Docs, AddDocs, UpdateDocs, ItemDocs } from './docs.schema'; @@ -32,7 +33,11 @@ export default class DocsService { await this.sharingService.syncSharingsByItemId(input.parentId, itemDocs.item.id); } - return this.formatitemDocs(itemDocs); + const docs = this.formatitemDocs(itemDocs); + + await ItemService.invalidateCachesForItem(docs); + + return docs; } public async getByItemId(itemId: number): Promise { @@ -71,15 +76,31 @@ export default class DocsService { }, }); - return this.formatitemDocs(itemDocs); + const docs = this.formatitemDocs(itemDocs); + + await ItemService.invalidateCachesForItem(docs); + + return docs; } public async deleteDocsByItemId(itemId: number): Promise { - await prisma.item.delete({ - where: { - id: itemId, - }, - }); + let docs: Docs; + + try { + docs = await this.getByItemId(itemId); + } catch (e) { + /* istanbul ignore next */ + return; + } + + await Promise.all([ + prisma.item.delete({ + where: { + id: itemId, + }, + }), + ItemService.invalidateCachesForItem(docs), + ]); } private formatitemDocs(itemDocs: ItemDocs): Docs { diff --git a/src/modules/item/folder/folder.controller.ts b/src/modules/item/folder/folder.controller.ts index 15b5926..bd0b6bd 100644 --- a/src/modules/item/folder/folder.controller.ts +++ b/src/modules/item/folder/folder.controller.ts @@ -23,7 +23,7 @@ export default class FolderController { try { const folder = await this.folderService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(folder.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(folder, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -42,14 +42,14 @@ export default class FolderController { try { const folder = await this.folderService.getByItemId(request.body.id); - if (!(await this.accessService.hasAccessToItem(folder.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(folder, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } if ( request.body.parentId !== null && request.body.parentId !== undefined && - !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + !(await this.accessService.hasAccessToItemId(request.body.parentId, request.user.sub)) ) { throw new UnauthorizedError('error.unauthorized'); } @@ -74,7 +74,7 @@ export default class FolderController { if ( request.body.parentId !== null && request.body.parentId !== undefined && - !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + !(await this.accessService.hasAccessToItemId(request.body.parentId, request.user.sub)) ) { throw new UnauthorizedError('error.unauthorized'); } @@ -103,7 +103,7 @@ export default class FolderController { try { const folder = await this.folderService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(folder.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(folder, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/folder/folder.service.ts b/src/modules/item/folder/folder.service.ts index 88e8c05..ac2d368 100644 --- a/src/modules/item/folder/folder.service.ts +++ b/src/modules/item/folder/folder.service.ts @@ -1,5 +1,6 @@ import { prisma } from '../../../plugins/prisma'; import { MissingError } from '../../../utils/error'; +import ItemService from '../item.service'; import SharingService from '../sharing/sharing.service'; import { Folder, AddFolder, UpdateFolder, ItemFolder } from './folder.schema'; @@ -32,7 +33,11 @@ export default class FolderService { await this.sharingService.syncSharingsByItemId(input.parentId, itemFolder.item.id); } - return this.formatItemFolder(itemFolder); + const folder = this.formatItemFolder(itemFolder); + + await ItemService.invalidateCachesForItem(folder); + + return folder; } public async getByItemId(itemId: number): Promise { @@ -71,15 +76,31 @@ export default class FolderService { }, }); - return this.formatItemFolder(itemFolder); + const folder = this.formatItemFolder(itemFolder); + + await ItemService.invalidateCachesForItem(folder); + + return folder; } public async deleteFolderByItemId(itemId: number): Promise { - await prisma.item.delete({ - where: { - id: itemId, - }, - }); + let folder: Folder; + + try { + folder = await this.getByItemId(itemId); + } catch (e) { + /* istanbul ignore next */ + return; + } + + await Promise.all([ + prisma.item.delete({ + where: { + id: itemId, + }, + }), + ItemService.invalidateCachesForItem(folder), + ]); } private formatItemFolder(itemFolder: ItemFolder): Folder { diff --git a/src/modules/item/item.controller.ts b/src/modules/item/item.controller.ts index 739adf2..acfe840 100644 --- a/src/modules/item/item.controller.ts +++ b/src/modules/item/item.controller.ts @@ -1,9 +1,18 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import ItemService from './item.service'; -import { ReadInput, itemSharingsInput, itemReadInput, itemBreadcrumbInput } from './item.schema'; +import { + ReadInput, + itemSharingsInput, + itemReadInput, + itemBreadcrumbInput, + ItemWithProperties, +} from './item.schema'; import AccessService from './sharing/access.service'; import { UnauthorizedError, errorReply } from '../../utils/error'; +export const CACHE_ITEMS = 'items'; +const CACHE_TTL = 86400; + export default class ItemController { private itemService: ItemService; private accessService: AccessService; @@ -26,7 +35,13 @@ export default class ItemController { public async itemRootHandler(request: FastifyRequest, reply: FastifyReply) { try { - const items = await this.itemService.getByOwnerIdAndParentId(request.user.sub, null); + const items = await request.redis.rememberJSON( + `${CACHE_ITEMS}:root:${request.user.sub}`, + CACHE_TTL, + async () => { + return await this.itemService.getByOwnerIdAndParentId(request.user.sub, null); + }, + ); return reply.code(200).send(items); } catch (e) { @@ -42,13 +57,21 @@ export default class ItemController { reply: FastifyReply, ) { try { - if (!(await this.accessService.hasAccessToItem(request.params.parentId, request.user.sub))) { + if ( + !(await this.accessService.hasAccessToItemId(request.params.parentId, request.user.sub)) + ) { throw new UnauthorizedError('error.unauthorized'); } - const items = await this.itemService.getAllOwnedAndSharredItemsByParentIdAndUserId( - request.user.sub, - request.params.parentId, + const items = await request.redis.rememberJSON( + `${CACHE_ITEMS}:${request.params.parentId}:${request.user.sub}`, + CACHE_TTL, + async () => { + return await this.itemService.getAllOwnedAndSharredItemsByParentIdAndUserId( + request.user.sub, + request.params.parentId, + ); + }, ); return reply.code(200).send(items); @@ -77,7 +100,7 @@ export default class ItemController { try { const id = Number.parseInt(request.params.id); - if (!(await this.accessService.hasAccessToItem(id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -98,7 +121,7 @@ export default class ItemController { try { const id = Number.parseInt(request.params.id); - if (!(await this.accessService.hasAccessToItem(id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -117,7 +140,7 @@ export default class ItemController { reply: FastifyReply, ) { try { - if (!(await this.accessService.hasAccessToItem(request.params.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(request.params.id, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/item.service.ts b/src/modules/item/item.service.ts index f12d2c8..968bc3f 100644 --- a/src/modules/item/item.service.ts +++ b/src/modules/item/item.service.ts @@ -1,5 +1,7 @@ import { prisma } from '../../plugins/prisma'; +import { redis } from '../../plugins/redis'; import { MissingError } from '../../utils/error'; +import { CACHE_ITEMS } from './item.controller'; import { CreateItem, Item, @@ -280,4 +282,23 @@ export default class ItemService { return this.formatItem(element); }); } + + public static async invalidateCachesForItem(item: Item): Promise { + await Promise.all([ + // Cache - Item + redis.invalidateCaches(`${CACHE_ITEMS}:${item.id}:*`), + // Cache - Parent + item.parentId !== null + ? redis.invalidateCaches(`${CACHE_ITEMS}:${item.parentId}:*`) + : // Cache - Root + redis.invalidateCaches(`${CACHE_ITEMS}:root:${item.ownerId}`), + ]); + } + + public static async invalidateCachesForUser(userId: number): Promise { + await Promise.all([ + // Cache - User + redis.invalidateCaches(`${CACHE_ITEMS}:*:${userId}`), + ]); + } } diff --git a/src/modules/item/sharing/__test__/access.service.test.ts b/src/modules/item/sharing/__test__/access.service.test.ts index d1b9a84..dac6a51 100644 --- a/src/modules/item/sharing/__test__/access.service.test.ts +++ b/src/modules/item/sharing/__test__/access.service.test.ts @@ -43,7 +43,7 @@ describe('ItemService', () => { mimeType: 'text/plain', }); - const hasAccessToItem = await accessService.hasAccessToItem(createdItem.id, user.id); + const hasAccessToItem = await accessService.hasAccessToItem(createdItem, user.id); expect(hasAccessToItem).toBeTruthy(); }); @@ -63,7 +63,7 @@ describe('ItemService', () => { otherUser.id, ); - const hasAccessToItem = await accessService.hasAccessToItem(createdItem.id, user.id); + const hasAccessToItem = await accessService.hasAccessToItem(createdItem, user.id); expect(hasAccessToItem).toBeTruthy(); }); @@ -76,13 +76,94 @@ describe('ItemService', () => { mimeType: 'text/plain', }); - const hasAccessToItem = await accessService.hasAccessToItem(createdItem.id, user.id); + const hasAccessToItem = await accessService.hasAccessToItem(createdItem, user.id); expect(hasAccessToItem).toBeFalsy(); }); + it("shouldn't care if item exists", async () => { + const hasAccessToItem = await accessService.hasAccessToItem( + { + id: 123456, + name: 'Test', + mimeType: 'text/plain', + ownerId: 43, + parentId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }, + 43, + ); + + const hasNotAccessToItem = await accessService.hasAccessToItem( + { + id: 123456, + name: 'Test', + mimeType: 'text/plain', + ownerId: 54364356, + parentId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }, + 43, + ); + + expect(hasAccessToItem).toBeTruthy(); + expect(hasNotAccessToItem).toBeFalsy(); + }); + }); + + describe('hasAccessToItemId()', () => { + it('should return true, when owner', async () => { + const createdItem = await itemService.createItem({ + name: 'test.txt', + ownerId: user.id, + parentId: null, + mimeType: 'text/plain', + }); + + const hasAccessToItemId = await accessService.hasAccessToItemId(createdItem.id, user.id); + + expect(hasAccessToItemId).toBeTruthy(); + }); + + it('should return true, when not owned by you, but shared with you', async () => { + const createdItem = await itemService.createItem({ + name: 'test.txt', + ownerId: otherUser.id, + parentId: null, + mimeType: 'text/plain', + }); + await sharingService.createSharing( + { + itemId: createdItem.id, + userId: user.id, + }, + otherUser.id, + ); + + const hasAccessToItemId = await accessService.hasAccessToItemId(createdItem.id, user.id); + + expect(hasAccessToItemId).toBeTruthy(); + }); + + it('should return false, when not owned or shared with you', async () => { + const createdItem = await itemService.createItem({ + name: 'test.txt', + ownerId: otherUser.id, + parentId: null, + mimeType: 'text/plain', + }); + + const hasAccessToItemId = await accessService.hasAccessToItemId(createdItem.id, user.id); + + expect(hasAccessToItemId).toBeFalsy(); + }); + it("should throw error, when item doesn't exist", async () => { - await expect(accessService.hasAccessToItem(1234, user.id)).rejects.toThrow(); + await expect(accessService.hasAccessToItemId(1234, user.id)).rejects.toThrow(); }); }); }); diff --git a/src/modules/item/sharing/access.service.ts b/src/modules/item/sharing/access.service.ts index f871b73..9664a25 100644 --- a/src/modules/item/sharing/access.service.ts +++ b/src/modules/item/sharing/access.service.ts @@ -1,5 +1,6 @@ import SharingService from './sharing.service'; import ItemService from '../item.service'; +import { Item } from '../item.schema'; export default class AccessService { private itemService: ItemService; @@ -10,15 +11,19 @@ export default class AccessService { this.sharingService = sharingService; } - public async hasAccessToItem(itemId: number, userId: number): Promise { + public async hasAccessToItemId(itemId: number, userId: number): Promise { const item = await this.itemService.getById(itemId); + return await this.hasAccessToItem(item, userId); + } + + public async hasAccessToItem(item: Item, userId: number): Promise { if (item.ownerId === userId) { return true; } try { - await this.sharingService.getByItemIdAndUserId(itemId, userId); + await this.sharingService.getByItemIdAndUserId(item.id, userId); return true; } catch (e) { diff --git a/src/modules/item/sharing/sharing.controller.ts b/src/modules/item/sharing/sharing.controller.ts index 7d0d44d..86cca8b 100644 --- a/src/modules/item/sharing/sharing.controller.ts +++ b/src/modules/item/sharing/sharing.controller.ts @@ -29,7 +29,7 @@ export default class SharingController { try { const sharing = await this.sharingService.getById(request.params.id); - if (!(await this.accessService.hasAccessToItem(sharing.itemId, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(sharing.itemId, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -48,7 +48,7 @@ export default class SharingController { try { const sharing = await this.sharingService.getById(request.body.id); - if (!(await this.accessService.hasAccessToItem(sharing.itemId, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(sharing.itemId, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -67,7 +67,7 @@ export default class SharingController { reply: FastifyReply, ) { try { - if (!(await this.accessService.hasAccessToItem(request.body.itemId, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(request.body.itemId, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -93,7 +93,7 @@ export default class SharingController { try { const sharing = await this.sharingService.getById(request.params.id); - if (!(await this.accessService.hasAccessToItem(sharing.itemId, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(sharing.itemId, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/sharing/sharing.service.ts b/src/modules/item/sharing/sharing.service.ts index 5f920bf..8e8c2cd 100644 --- a/src/modules/item/sharing/sharing.service.ts +++ b/src/modules/item/sharing/sharing.service.ts @@ -58,6 +58,11 @@ export default class SharingService { skipDuplicates: true, }); + await Promise.all([ + ItemService.invalidateCachesForUser(input.userId), + ItemService.invalidateCachesForUser(userId), + ]); + try { await this.getByItemIdAndUserId(input.itemId, input.userId); @@ -111,6 +116,11 @@ export default class SharingService { ], }, }); + + await Promise.all([ + ItemService.invalidateCachesForUser(input.userId), + ItemService.invalidateCachesForUser(userId), + ]); } catch (e) { // Nothing to do here } @@ -129,6 +139,8 @@ export default class SharingService { }, }); + await ItemService.invalidateCachesForUser(input.userId); + return itemSharing; } @@ -161,6 +173,8 @@ export default class SharingService { ], }, }); + + await ItemService.invalidateCachesForUser(userId); } catch (e) { // Nothing to do here } @@ -181,6 +195,12 @@ export default class SharingService { userIds.push(sharing.userId); }); + await Promise.all( + userIds.map(async (userId) => { + await ItemService.invalidateCachesForUser(userId); + }), + ); + await prisma.itemSharing.createMany({ data: userIds.map((userId) => { return { diff --git a/src/modules/item/shortcut/shortcut.controller.ts b/src/modules/item/shortcut/shortcut.controller.ts index e02fd58..0999f46 100644 --- a/src/modules/item/shortcut/shortcut.controller.ts +++ b/src/modules/item/shortcut/shortcut.controller.ts @@ -23,7 +23,7 @@ export default class ShortcutController { try { const shortcut = await this.shortcutService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(shortcut.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(shortcut, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } @@ -42,14 +42,14 @@ export default class ShortcutController { try { const shortcut = await this.shortcutService.getByItemId(request.body.id); - if (!(await this.accessService.hasAccessToItem(shortcut.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(shortcut, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } if ( request.body.parentId !== null && request.body.parentId !== undefined && - !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + !(await this.accessService.hasAccessToItemId(request.body.parentId, request.user.sub)) ) { throw new UnauthorizedError('error.unauthorized'); } @@ -74,7 +74,7 @@ export default class ShortcutController { if ( request.body.parentId !== null && request.body.parentId !== undefined && - !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + !(await this.accessService.hasAccessToItemId(request.body.parentId, request.user.sub)) ) { throw new UnauthorizedError('error.unauthorized'); } @@ -103,7 +103,7 @@ export default class ShortcutController { try { const shortcut = await this.shortcutService.getByItemId(request.params.id); - if (!(await this.accessService.hasAccessToItem(shortcut.id, request.user.sub))) { + if (!(await this.accessService.hasAccessToItem(shortcut, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/modules/item/shortcut/shortcut.service.ts b/src/modules/item/shortcut/shortcut.service.ts index d517a99..1f2cf6e 100644 --- a/src/modules/item/shortcut/shortcut.service.ts +++ b/src/modules/item/shortcut/shortcut.service.ts @@ -1,5 +1,6 @@ import { prisma } from '../../../plugins/prisma'; import { MissingError } from '../../../utils/error'; +import ItemService from '../item.service'; import SharingService from '../sharing/sharing.service'; import { Shortcut, AddShortcut, UpdateShortcut, ItemShortcut } from './shortcut.schema'; @@ -36,7 +37,11 @@ export default class ShortcutService { await this.sharingService.syncSharingsByItemId(input.parentId, itemShortcut.shortcutItem.id); } - return this.formatItemShortcut(itemShortcut); + const shortcut = this.formatItemShortcut(itemShortcut); + + await ItemService.invalidateCachesForItem(shortcut); + + return shortcut; } public async getByItemId(itemId: number): Promise { @@ -74,15 +79,31 @@ export default class ShortcutService { }, }); - return this.formatItemShortcut(itemShortcut); + const shortcut = this.formatItemShortcut(itemShortcut); + + await ItemService.invalidateCachesForItem(shortcut); + + return shortcut; } public async deleteShortcutByItemId(itemId: number): Promise { - await prisma.item.delete({ - where: { - id: itemId, - }, - }); + let shortcut: Shortcut; + + try { + shortcut = await this.getByItemId(itemId); + } catch (e) { + /* istanbul ignore next */ + return; + } + + await Promise.all([ + prisma.item.delete({ + where: { + id: itemId, + }, + }), + ItemService.invalidateCachesForItem(shortcut), + ]); } private formatItemShortcut(itemShortcut: ItemShortcut): Shortcut { diff --git a/src/modules/item/starred/starred.controller.ts b/src/modules/item/starred/starred.controller.ts index e333638..542d3ed 100644 --- a/src/modules/item/starred/starred.controller.ts +++ b/src/modules/item/starred/starred.controller.ts @@ -20,7 +20,7 @@ export default class StarredController { reply: FastifyReply, ) { try { - if (!(await this.accessService.hasAccessToItem(request.body.itemId, request.user.sub))) { + if (!(await this.accessService.hasAccessToItemId(request.body.itemId, request.user.sub))) { throw new UnauthorizedError('error.unauthorized'); } diff --git a/src/plugins/redis.ts b/src/plugins/redis.ts index 20d2b2a..1d7a259 100644 --- a/src/plugins/redis.ts +++ b/src/plugins/redis.ts @@ -27,7 +27,7 @@ export default fastifyPlugin( redis = new Redis(fastify.config.REDIS_URL, { keyPrefix: fastify.config.NODE_ENV === 'test' - ? /* istanbul ignore next */ v4() + ? /* istanbul ignore next */ `${v4()}:` : /* istanbul ignore next */ undefined, lazyConnect: true, }).on( @@ -45,7 +45,7 @@ export default fastifyPlugin( }, ); - redis.remember = async (key, ttl, callback) => { + redis.remember = async (key, ttl_seconds, callback) => { let value = await redis.get(key); if (value !== null) { @@ -54,14 +54,14 @@ export default fastifyPlugin( value = await callback(); - await redis.setex(key, ttl, value); + await redis.setex(key, ttl_seconds, value); return value; }; - redis.rememberJSON = async (key, ttl, callback) => { + redis.rememberJSON = async (key, ttl_seconds, callback) => { return JSON.parse( - await redis.remember(key, ttl, async () => { + await redis.remember(key, ttl_seconds, async () => { return JSON.stringify(await callback()); }), ); @@ -70,6 +70,28 @@ export default fastifyPlugin( redis.invalidateCaches = async (...keys) => { await Promise.all( keys.map(async (key) => { + // If the key is a pattern, delete all keys matching the pattern + if (key.includes('*')) { + // Get all keys matching pattern + keys = await redis.keys( + `${ + redis.options.keyPrefix ? redis.options.keyPrefix : /* istanbul ignore next */ '' + }${key}`, + ); + + await Promise.all( + keys.map(async (key) => { + if (redis.options.keyPrefix) { + await redis.del(key.replace(redis.options.keyPrefix, '')); + return; + } + + /* istanbul ignore next */ + await redis.del(key); + }), + ); + } + await redis.del(key); }), );