Skip to content

Commit

Permalink
#27 - Added & refactored sharing endpoints
Browse files Browse the repository at this point in the history
frederikpyt committed Oct 11, 2023
1 parent 3a6b0c2 commit db0de0e
Showing 13 changed files with 785 additions and 27 deletions.
5 changes: 5 additions & 0 deletions src/locales/da.json
Original file line number Diff line number Diff line change
@@ -99,6 +99,11 @@
"required": "userId er påkrævet",
"type": "userId skal være et tal"
},
"email": {
"required": "Email er påkrævet",
"type": "Email skal være en tekst",
"format": "Email skal være af korrekt format"
},
"notFound": "Delning ikke fundet",
"alreadyExists": "Delning findes allerede"
},
5 changes: 5 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
@@ -99,6 +99,11 @@
"required": "userId is required",
"type": "userId must be a number"
},
"email": {
"required": "Email is required",
"type": "Email must be a number",
"format": "Email must be of correct format"
},
"notFound": "Sharing not found",
"alreadyExists": "Sharing already exists"
},
6 changes: 6 additions & 0 deletions src/modules/item/__test__/item.service.test.ts
Original file line number Diff line number Diff line change
@@ -419,4 +419,10 @@ describe('ItemService', () => {
await expect(itemService.getById(1234)).rejects.toThrow();
});
});

describe('getItemByIdWithSharingsAndOwner()', () => {
it("should throw error, when item doesn't exist", async () => {
await expect(itemService.getItemByIdWithSharingsAndOwner(1234)).rejects.toThrow();
});
});
});
281 changes: 281 additions & 0 deletions src/modules/item/__test__/item.shared.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
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 ItemService from '../item.service';

describe('GET /api/item/shared', () => {
let userService: UserService;
let folderService: FolderService;
let authService: AuthService;
let blobService: BlobService;
let docsService: DocsService;
let shortcutService: ShortcutService;
let sharingService: SharingService;

let user: User;
let otherUser: User;

beforeAll(async () => {
userService = new UserService();
folderService = new FolderService();
authService = new AuthService();
blobService = new BlobService();
docsService = new DocsService();
shortcutService = new ShortcutService();
sharingService = new SharingService(new ItemService());

user = await userService.createUser({
name: 'Joe Biden the 1st',
email: 'joe@biden.com',
password: '1234',
});
otherUser = await userService.createUser({
name: 'Joe Biden the 2nd',
email: 'joe2@biden.com',
password: '4321',
});
});

it('Should return status 200 and all items from parentId', async () => {
const { accessToken } = await authService.createTokens(user.id);

const folder1 = await folderService.createFolder({
name: 'Folder1',
color: '#123456',
ownerId: otherUser.id,
parentId: null,
});
await sharingService.createSharing(
{
itemId: folder1.id,
userId: user.id,
},
otherUser.id,
);

const blob = await blobService.createBlob({
mimeType: 'text/plain',
name: 'test1.txt',
ownerId: otherUser.id,
parentId: folder1.id,
blobUrl: 'https://example.com/test1.txt',
});
await sharingService.createSharing(
{
itemId: blob.id,
userId: user.id,
},
otherUser.id,
);

const folder2 = await folderService.createFolder({
name: 'Folder2',
color: '#987654',
ownerId: otherUser.id,
parentId: folder1.id,
});
await sharingService.createSharing(
{
itemId: folder2.id,
userId: user.id,
},
otherUser.id,
);

const docs = await docsService.createDocs({
name: 'Docs1',
text: 'Docs1 text',
ownerId: otherUser.id,
parentId: folder1.id,
});
await sharingService.createSharing(
{
itemId: docs.id,
userId: user.id,
},
otherUser.id,
);

const shortcut = await shortcutService.createShortcut({
name: 'Shortcut',
ownerId: otherUser.id,
linkedItemId: blob.id,
parentId: folder1.id,
});
await sharingService.createSharing(
{
itemId: shortcut.id,
userId: user.id,
},
otherUser.id,
);

const response = await global.fastify.inject({
method: 'GET',
url: '/api/item/shared',
headers: {
authorization: 'Bearer ' + accessToken,
},
});

console.log(response.json());

expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([
{
id: expect.any(Number),
name: 'Folder1',
color: '#123456',
parentId: null,
ownerId: otherUser.id,
isStarred: false,
mimeType: 'application/vnd.cloudstore.folder',
createdAt: expect.any(String),
deletedAt: null,
updatedAt: expect.any(String),
},
{
id: expect.any(Number),
name: 'Folder2',
color: '#987654',
parentId: folder1.id,
ownerId: otherUser.id,
isStarred: false,
mimeType: 'application/vnd.cloudstore.folder',
createdAt: expect.any(String),
deletedAt: null,
updatedAt: expect.any(String),
},
{
id: expect.any(Number),
name: 'Shortcut',
parentId: folder1.id,
ownerId: otherUser.id,
isStarred: false,
mimeType: 'application/vnd.cloudstore.shortcut',
createdAt: expect.any(String),
deletedAt: null,
updatedAt: expect.any(String),
},
{
id: expect.any(Number),
name: 'Docs1',
text: 'Docs1 text',
parentId: folder1.id,
ownerId: otherUser.id,
isStarred: false,
mimeType: 'application/vnd.cloudstore.docs',
createdAt: expect.any(String),
deletedAt: null,
updatedAt: expect.any(String),
},
{
id: expect.any(Number),
name: 'test1.txt',
blobUrl: 'https://example.com/test1.txt',
parentId: folder1.id,
ownerId: otherUser.id,
isStarred: false,
mimeType: 'text/plain',
createdAt: expect.any(String),
deletedAt: null,
updatedAt: expect.any(String),
},
]);
});

it('Should return status 401, when unauthorized', async () => {
const folder1 = await folderService.createFolder({
name: 'Folder1',
color: '#123456',
ownerId: otherUser.id,
parentId: null,
});
await sharingService.createSharing(
{
itemId: folder1.id,
userId: user.id,
},
otherUser.id,
);

const blob = await blobService.createBlob({
mimeType: 'text/plain',
name: 'test1.txt',
ownerId: otherUser.id,
parentId: folder1.id,
blobUrl: 'https://example.com/test1.txt',
});
await sharingService.createSharing(
{
itemId: blob.id,
userId: user.id,
},
otherUser.id,
);

const folder2 = await folderService.createFolder({
name: 'Folder2',
color: '#987654',
ownerId: otherUser.id,
parentId: folder1.id,
});
await sharingService.createSharing(
{
itemId: folder2.id,
userId: user.id,
},
otherUser.id,
);

const docs = await docsService.createDocs({
name: 'Docs1',
text: 'Docs1 text',
ownerId: otherUser.id,
parentId: folder1.id,
});
await sharingService.createSharing(
{
itemId: docs.id,
userId: user.id,
},
otherUser.id,
);

const shortcut = await shortcutService.createShortcut({
name: 'Shortcut',
ownerId: otherUser.id,
linkedItemId: blob.id,
parentId: folder1.id,
});
await sharingService.createSharing(
{
itemId: shortcut.id,
userId: user.id,
},
otherUser.id,
);

const response = await global.fastify.inject({
method: 'GET',
url: '/api/item/shared',
headers: {
authorization: 'invalid_token!!',
},
});

expect(response.statusCode).toBe(401);
expect(response.json()).toEqual({
error: 'UnauthorizedError',
errors: {
_: ['Unauthorized'],
},
statusCode: 401,
});
});
});
177 changes: 177 additions & 0 deletions src/modules/item/__test__/item.sharings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { User } from '@prisma/client';
import UserService from '../../auth/user.service';
import FolderService from '../folder/folder.service';
import AuthService from '../../auth/auth.service';
import SharingService from '../sharing/sharing.service';
import ItemService from '../item.service';

describe('GET /api/item/:id/sharings', () => {
let userService: UserService;
let folderService: FolderService;
let authService: AuthService;
let sharingService: SharingService;

let user: User;
let otherUser: User;

beforeAll(async () => {
userService = new UserService();
folderService = new FolderService();
authService = new AuthService();
sharingService = new SharingService(new ItemService());

user = await userService.createUser({
name: 'Joe Biden the 1st',
email: 'joe@biden.com',
password: '1234',
});
otherUser = await userService.createUser({
name: 'Joe Biden the 2nd',
email: 'joe2@biden.com',
password: '4321',
});
});

it("Should return status 200, item, it's sharings, their users and the owner by itemId", async () => {
const { accessToken } = await authService.createTokens(user.id);

const folder = await folderService.createFolder({
name: 'Folder1',
color: '#123456',
ownerId: user.id,
parentId: null,
});
const sharing = await sharingService.createSharing(
{
itemId: folder.id,
userId: otherUser.id,
},
user.id,
);

const response = await global.fastify.inject({
method: 'GET',
url: `/api/item/${folder.id}/sharings`,
headers: {
authorization: 'Bearer ' + accessToken,
},
});

expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
id: expect.any(Number),
name: 'Folder1',
parentId: null,
ownerId: user.id.toString(),
owner: {
id: user.id,
name: user.name,
email: user.email,
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
ItemSharing: [
{
id: sharing.id,
userId: sharing.userId,
user: {
id: otherUser.id,
name: otherUser.name,
email: otherUser.email,
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
itemId: sharing.itemId,
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
],
mimeType: 'application/vnd.cloudstore.folder',
createdAt: expect.any(String),
updatedAt: expect.any(String),
deletedAt: null,
});
});

it("Should return status 200, item, it's sharings, their users and the owner by itemId", async () => {
const { accessToken } = await authService.createTokens(user.id);

const response = await global.fastify.inject({
method: 'GET',
url: `/api/item/1234/sharings`,
headers: {
authorization: 'Bearer ' + accessToken,
},
});

expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
error: 'BadRequestError',
errors: {
_: ['Item not found'],
},
statusCode: 400,
});
});

it("Should return status 401, when you don't have access to the item", async () => {
const { accessToken } = await authService.createTokens(otherUser.id);

const folder = await folderService.createFolder({
name: 'Folder1',
color: '#123456',
ownerId: user.id,
parentId: null,
});

const response = await global.fastify.inject({
method: 'GET',
url: `/api/item/${folder.id}/sharings`,
headers: {
authorization: 'Bearer ' + accessToken,
},
});

expect(response.statusCode).toBe(401);
expect(response.json()).toEqual({
error: 'UnauthorizedError',
errors: {
_: ['Unauthorized'],
},
statusCode: 401,
});
});

it('Should return status 401, when unauthorized', async () => {
const folder = await folderService.createFolder({
name: 'Folder1',
color: '#123456',
ownerId: user.id,
parentId: null,
});
await sharingService.createSharing(
{
itemId: folder.id,
userId: otherUser.id,
},
user.id,
);

const response = await global.fastify.inject({
method: 'GET',
url: `/api/item/${folder.id}/sharings`,
headers: {
authorization: 'invalid_token!!',
},
});

expect(response.statusCode).toBe(401);
expect(response.json()).toEqual({
error: 'UnauthorizedError',
errors: {
_: ['Unauthorized'],
},
statusCode: 401,
});
});
});
39 changes: 38 additions & 1 deletion src/modules/item/item.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import ItemService from './item.service';
import { ReadInput } from './item.schema';
import { ReadInput, itemSharingsInput } from './item.schema';
import AccessService from './sharing/access.service';

export default class ItemController {
@@ -56,4 +56,41 @@ export default class ItemController {
return reply.badRequest();
}
}

public async sharedItemHandler(request: FastifyRequest, reply: FastifyReply) {
try {
const items = await this.itemService.getAllSharedItemsByUserId(request.user.sub);

return reply.code(200).send(items);
} catch (e) {
/* istanbul ignore next */
return reply.badRequest();
}
}

public async sharingsHandler(
request: FastifyRequest<{
Params: itemSharingsInput;
}>,
reply: FastifyReply,
) {
try {
const id = Number.parseInt(request.params.id);

if (!(await this.accessService.hasAccessToItem(id, request.user.sub))) {
return reply.unauthorized();
}

const item = await this.itemService.getItemByIdWithSharingsAndOwner(id);

return reply.code(200).send(item);
} catch (e) {
if (e instanceof Error) {
return reply.badRequest(request.i18n.t(e.message));
}

/* istanbul ignore next */
return reply.badRequest();
}
}
}
38 changes: 38 additions & 0 deletions src/modules/item/item.route.ts
Original file line number Diff line number Diff line change
@@ -68,4 +68,42 @@ export default async (fastify: FastifyInstance) => {
},
itemController.itemHandler.bind(itemController),
);

fastify.get(
'/shared',
{
schema: {
tags: ['Item'],
response: {
200: { $ref: 'itemsResponseSchema' },
},
security: [
{
bearerAuth: [],
},
],
},
onRequest: [fastify.authenticate],
},
itemController.sharedItemHandler.bind(itemController),
);

fastify.get(
'/:id/sharings',
{
schema: {
tags: ['Item'],
response: {
200: { $ref: 'itemSharingsResponseSchema' },
},
security: [
{
bearerAuth: [],
},
],
},
onRequest: [fastify.authenticate],
},
itemController.sharingsHandler.bind(itemController),
);
};
116 changes: 115 additions & 1 deletion src/modules/item/item.schema.ts
Original file line number Diff line number Diff line change
@@ -91,6 +91,120 @@ const itemsResponseSchema = {
},
} as const;

const itemSharingsSchema = {
$id: 'itemSharingsSchema',
type: 'object',
properties: {
id: {
type: 'string',
errorMessage: {
type: 'item.id.type',
},
},
},
required: ['id'],
errorMessage: {
required: {
id: 'item.id.required',
},
},
} as const;

const itemSharingsResponseSchema = {
$id: 'itemSharingsResponseSchema',
type: 'object',
properties: {
id: {
type: 'number',
},
name: {
type: 'string',
},
mimeType: {
type: 'string',
},
ownerId: {
type: 'string',
},
parentId: {
type: ['number', 'null'],
},
owner: {
type: 'object',
properties: {
id: {
type: 'number',
},
email: {
type: 'string',
},
name: {
type: 'string',
},
createdAt: {
type: 'string',
},
updatedAt: {
type: 'string',
},
},
},
ItemSharing: {
type: 'array',
items: {
type: 'object',
properties: {
id: {
type: 'number',
},
userId: {
type: 'number',
},
user: {
type: 'object',
properties: {
id: {
type: 'number',
},
email: {
type: 'string',
},
name: {
type: 'string',
},
createdAt: {
type: 'string',
},
updatedAt: {
type: 'string',
},
},
},
itemId: {
type: 'number',
},
createdAt: {
type: 'string',
},
updatedAt: {
type: 'string',
},
},
},
},
deletedAt: {
type: ['string', 'null'],
},
createdAt: {
type: 'string',
},
updatedAt: {
type: 'string',
},
},
} as const;

export type itemSharingsInput = FromSchema<typeof itemSharingsSchema>;
export type ReadInput = FromSchema<typeof readItemsSchema>;

export const itemSchemas = [readItemsSchema, itemsResponseSchema];
export const itemSchemas = [readItemsSchema, itemsResponseSchema, itemSharingsResponseSchema];
47 changes: 47 additions & 0 deletions src/modules/item/item.service.ts
Original file line number Diff line number Diff line change
@@ -141,6 +141,53 @@ export default class ItemService {
return this.formatItems(items);
}

public async getAllSharedItemsByUserId(userId: number) {
const items = await prisma.item.findMany({
where: {
ItemSharing: {
some: {
userId: userId,
},
},
},
include: {
ItemBlob: true,
ItemFolder: true,
ItemDocs: true,
ItemShortcut: true,
ItemStarred: {
where: {
userId: userId,
},
},
},
});

return this.formatItems(items);
}

public async getItemByIdWithSharingsAndOwner(id: number) {
const item = await prisma.item.findUnique({
where: {
id,
},
include: {
owner: true,
ItemSharing: {
include: {
user: true,
},
},
},
});

if (!item) {
throw new Error('item.notFound');
}

return item;
}

private formatItems(items: ItemPrismaProperties[]): ItemWithProperties[] {
return items.map((element) => {
const { ItemFolder, ItemBlob, ItemDocs, ItemShortcut, ItemStarred, ...strippedElement } =
56 changes: 44 additions & 12 deletions src/modules/item/sharing/__test__/add.test.ts
Original file line number Diff line number Diff line change
@@ -52,7 +52,7 @@ describe('POST /api/sharing', () => {
},
payload: {
itemId: item.id,
userId: user.id,
email: user.email,
},
});

@@ -112,7 +112,7 @@ describe('POST /api/sharing', () => {
},
payload: {
itemId: folder.id,
userId: otherUser.id,
email: otherUser.email,
},
});

@@ -157,7 +157,7 @@ describe('POST /api/sharing', () => {
},
payload: {
itemId: item.id,
userId: user.id,
email: user.email,
},
});

@@ -189,7 +189,7 @@ describe('POST /api/sharing', () => {
},
payload: {
itemId: item.id,
userId: user.id,
email: user.email,
},
});

@@ -203,6 +203,38 @@ describe('POST /api/sharing', () => {
});
});

it("should return status 400, when user with email doesn't exist", async () => {
const { accessToken } = await authService.createTokens(user.id);

const item = await itemService.createItem({
name: 'test.txt',
ownerId: user.id,
parentId: null,
mimeType: 'text/plain',
});

const response = await global.fastify.inject({
method: 'POST',
url: '/api/sharing',
headers: {
authorization: 'Bearer ' + accessToken,
},
payload: {
itemId: item.id,
email: 'user@whodoesnotexist.com',
},
});

expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
error: 'BadRequestError',
errors: {
_: ['User not found'],
},
statusCode: 400,
});
});

it('should return status 400, when sharing already exists', async () => {
const { accessToken } = await authService.createTokens(user.id);

@@ -228,7 +260,7 @@ describe('POST /api/sharing', () => {
authorization: 'Bearer ' + accessToken,
},
payload: {
userId: user.id,
email: user.email,
itemId: item.id,
},
});
@@ -243,7 +275,7 @@ describe('POST /api/sharing', () => {
});
});

it("should return status 400, when user id isn't provided", async () => {
it("should return status 400, when email isn't provided", async () => {
const { accessToken } = await authService.createTokens(user.id);

const item = await itemService.createItem({
@@ -268,13 +300,13 @@ describe('POST /api/sharing', () => {
expect(response.json()).toEqual({
error: 'ValidationError',
errors: {
_: ['userId is required'],
_: ['Email is required'],
},
statusCode: 400,
});
});

it("should return status 400, when user id isn't a number", async () => {
it("should return status 400, when email isn't a valid email", async () => {
const { accessToken } = await authService.createTokens(user.id);

const item = await itemService.createItem({
@@ -292,15 +324,15 @@ describe('POST /api/sharing', () => {
},
payload: {
itemId: item.id,
userId: 'invalid_id',
email: 'invalid_email',
},
});

expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
error: 'ValidationError',
errors: {
userId: ['userId must be a number'],
email: ['Email must be of correct format'],
},
statusCode: 400,
});
@@ -323,7 +355,7 @@ describe('POST /api/sharing', () => {
authorization: 'Bearer ' + accessToken,
},
payload: {
userId: user.id,
email: user.email,
},
});

@@ -355,7 +387,7 @@ describe('POST /api/sharing', () => {
},
payload: {
itemId: 'invalid_id',
userId: user.id,
email: user.email,
},
});

16 changes: 14 additions & 2 deletions src/modules/item/sharing/sharing.controller.ts
Original file line number Diff line number Diff line change
@@ -2,14 +2,21 @@ import { FastifyReply, FastifyRequest } from 'fastify';
import { AddInput, ReadInput, EditInput, DeleteInput } from './sharing.schema';
import SharingService from './sharing.service';
import AccessService from './access.service';
import UserService from '../../auth/user.service';

export default class SharingController {
private sharingService: SharingService;
private accessService: AccessService;
private userService: UserService;

constructor(sharingService: SharingService, accessService: AccessService) {
constructor(
sharingService: SharingService,
accessService: AccessService,
userService: UserService,
) {
this.sharingService = sharingService;
this.accessService = accessService;
this.userService = userService;
}

public async readHandler(
@@ -73,7 +80,12 @@ export default class SharingController {
return reply.unauthorized();
}

const sharing = await this.sharingService.createSharing(request.body, request.user.sub);
const user = await this.userService.getUserByEmail(request.body.email);

const sharing = await this.sharingService.createSharing(
{ userId: user.id, itemId: request.body.itemId },
request.user.sub,
);

return reply.code(200).send(sharing);
} catch (e) {
6 changes: 4 additions & 2 deletions src/modules/item/sharing/sharing.route.ts
Original file line number Diff line number Diff line change
@@ -3,13 +3,15 @@ import SharingController from './sharing.controller';
import SharingService from './sharing.service';
import AccessService from './access.service';
import ItemService from '../item.service';
import UserService from '../../auth/user.service';

export default async (fastify: FastifyInstance) => {
const itemService = new ItemService();
const sharingService = new SharingService(itemService);
const sharingController = new SharingController(
sharingService,
new AccessService(itemService, sharingService),
new UserService(),
);

fastify.get(
@@ -61,9 +63,9 @@ export default async (fastify: FastifyInstance) => {
{
schema: {
tags: ['Sharing'],
body: { $ref: 'uploadSharingSchema' },
body: { $ref: 'addSharingSchema' },
response: {
200: { $ref: 'uploadSharingResponseSchema' },
200: { $ref: 'addSharingResponseSchema' },
},
security: [
{
20 changes: 11 additions & 9 deletions src/modules/item/sharing/sharing.schema.ts
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ export type UpdateSharing = {
} & Partial<CreateSharing>;

const addSharingSchema = {
$id: 'uploadSharingSchema',
$id: 'addSharingSchema',
type: 'object',
properties: {
itemId: {
@@ -22,25 +22,27 @@ const addSharingSchema = {
type: 'item.sharing.itemId.type',
},
},
userId: {
type: 'number',
email: {
type: 'string',
format: 'email',
errorMessage: {
type: 'item.sharing.userId.type',
type: 'item.sharing.email.type',
format: 'item.sharing.email.format',
},
},
},
required: ['itemId', 'userId'],
required: ['itemId', 'email'],
errorMessage: {
required: {
itemId: 'item.sharing.itemId.required',
userId: 'item.sharing.userId.required',
email: 'item.sharing.email.required',
},
},
} as const;
export type AddInput = FromSchema<typeof addSharingSchema>;

const uploadSharingResponseSchema = {
$id: 'uploadSharingResponseSchema',
const addSharingResponseSchema = {
$id: 'addSharingResponseSchema',
type: 'object',
properties: {
id: {
@@ -181,7 +183,7 @@ export type DeleteInput = FromSchema<typeof deleteSharingSchema>;

export const sharingSchemas = [
addSharingSchema,
uploadSharingResponseSchema,
addSharingResponseSchema,
readSharingSchema,
readSharingResponseSchema,
editSharingSchema,

0 comments on commit db0de0e

Please sign in to comment.