Extract comments functionality to packages/comments-server

This commit is contained in:
Jerko Steiner 2019-03-13 11:24:08 +05:00
parent 2fc22f05b7
commit 155c98d000
36 changed files with 18 additions and 1341 deletions

View File

@ -1,9 +1,6 @@
import * as comment from '../comment'
import * as middleware from '../middleware'
import * as routes from '../routes'
import * as services from '../services'
import * as site from '../site'
import * as story from '../story'
import * as team from '../team'
import * as user from '../user'
import express from 'express'
@ -23,9 +20,6 @@ export class Application implements IApplication {
readonly userService: services.IUserService
readonly teamService: team.ITeamService
readonly siteService: site.ISiteService
readonly storyService: story.IStoryService
readonly commentService: comment.ICommentService
readonly userPermissions: user.IUserPermissions
readonly authenticator: middleware.Authenticator
@ -37,10 +31,6 @@ export class Application implements IApplication {
this.userService = new services.UserService(this.transactionManager)
this.teamService = new team.TeamService(this.transactionManager)
this.siteService = new site.SiteService(this.transactionManager)
this.storyService = new story.StoryService(
this.transactionManager, this.siteService)
this.commentService = new comment.CommentService(this.transactionManager)
this.userPermissions = new user.UserPermissions(this.transactionManager)
this.authenticator = new middleware.Authenticator(this.userService)
@ -61,9 +51,10 @@ export class Application implements IApplication {
this.configureMiddleware(router)
this.configureRouter(router)
this.configureApiErrorHandling(router)
server.use(this.config.app.context, router)
this.configureErrorHandling(server)
this.configureGlobalErrorHandling(server)
return server
}
@ -87,8 +78,6 @@ export class Application implements IApplication {
}
protected configureRouter(router: express.Router) {
const apiLogger = this.getApiLogger()
router.use('/app', new routes.LoginRoutes(
this.userService,
this.authenticator,
@ -108,27 +97,14 @@ export class Application implements IApplication {
this.userPermissions,
this.createTransactionalRouter(),
).handle)
}
router.use('/api', new site.SiteRoutes(
this.siteService,
this.userPermissions,
this.createTransactionalRouter(),
).handle)
router.use('/api', new story.StoryRoutes(
this.storyService,
this.createTransactionalRouter(),
).handle)
router.use('/api', new comment.CommentRoutes(
this.commentService,
this.createTransactionalRouter(),
).handle)
protected configureApiErrorHandling(router: express.Router) {
const apiLogger = this.getApiLogger()
router.use('/api', new middleware.ErrorApiHandler(apiLogger).handle)
}
protected configureErrorHandling(server: express.Application) {
protected configureGlobalErrorHandling(server: express.Application) {
const apiLogger = this.getApiLogger()
server.use(new middleware.ErrorPageHandler(apiLogger).handle)
}

View File

@ -6,6 +6,7 @@ import {config} from './config'
import {Bootstrap} from './application/Bootstrap'
export const bootstrap = new Bootstrap(config)
// FIXME determine a port by parsing app url from config
const port: string | number = process.env.PORT || 3000
bootstrap.listen(port)

View File

@ -1,231 +0,0 @@
import * as CommentTestUtils from './CommentTestUtils'
import {IStory} from '@rondo/common'
import {createSite} from '../site/SiteTestUtils'
import {getStory} from '../story/StoryTestUtils'
import {test} from '../test'
describe('comment', () => {
test.withDatabase()
const t = test.request('/api')
let cookie!: string
let token!: string
let story!: IStory
const storyUrl = 'https://test.example.com/my/story'
beforeEach(async () => {
const session = await test.registerAccount()
cookie = session.cookie
token = session.token
t.setHeaders({cookie, 'x-csrf-token': token})
await createSite(t, 'test.example.com')
story = await getStory(t, storyUrl)
})
async function createComment() {
return CommentTestUtils.createRootComment(t, {
storyId: story.id,
message: 'test',
})
}
async function createChildComment() {
const parent = await CommentTestUtils.createRootComment(t, {
storyId: story.id,
message: 'this is a parent comment',
})
const child = await CommentTestUtils.createComment(t, {
storyId: story.id,
parentId: parent.id,
message: 'this is a child comment',
})
expect(child.id).toBeGreaterThan(0)
return child
}
describe('GET /stories/:storyId/comments', () => {
it('retrieves comments by story id', async () => {
const comments = await CommentTestUtils.getComments(t, story.id)
expect(comments.rootIds).toEqual(jasmine.any(Array))
})
})
describe('POST /stories/:storyId/comments', () => {
it('adds a new root comment', async () => {
const message = 'this is a comment'
const comment = await CommentTestUtils.createRootComment(t, {
storyId: story.id,
message,
})
expect(comment.id).toBeGreaterThan(0)
expect(comment.message).toEqual(message)
})
})
describe('POST /stories/:storyId/comments/:parentId', () => {
it('adds a new child comment', async () => {
await createChildComment()
const comments = await CommentTestUtils.getComments(t, story.id)
expect(comments.rootIds).toEqual([
jasmine.any(Number),
])
const id = comments.rootIds[0]
const parent = comments.commentsById[id]
expect(parent).toBeTruthy()
expect(parent.message).toMatch(/parent/)
expect(parent.childrenIds).toEqual([jasmine.any(Number)])
const child = comments.commentsById[parent.childrenIds![0]]
expect(child).toBeTruthy()
expect(child.message).toMatch(/child/)
expect(child.childrenIds).toBe(undefined)
})
})
describe('PUT /comments/:commentId', () => {
it('updates a comment', async () => {
const comment = await createComment()
await t.put('/comments/:commentId', {
params: {
commentId: comment.id,
},
})
.send({
message: 'test2',
})
.expect(200)
const c = await CommentTestUtils.getCommentById(t, comment.id)
expect(c.message).toEqual('test2')
// TODO save edit history
})
// it('fails to update a comment if user is not the owner')
// it('updates a comment if user is site moderator') // TODO later
})
describe('DELETE /comments/:commentId', () => {
it('soft deletes a comment', async () => {
const comment = await createComment()
await t.delete('/comments/:commentId', {
params: {
commentId: comment.id,
},
})
.expect(200)
const comment2 = await CommentTestUtils.getCommentById(t, comment.id)
expect(comment2.message)
.toEqual('(this message has been removed)')
})
})
describe('POST /comments/:commentId/vote', () => {
it('adds a new comment vote', async () => {
const comment = await createComment()
await CommentTestUtils.upVote(t, comment.id)
const c = await CommentTestUtils.getCommentById(t, comment.id)
expect(c.votes).toEqual(1)
})
it('can only upvote once', async () => {
const comment = await createComment()
async function upVote() {
return t.post('/comments/:commentId/vote', {
params: {
commentId: comment.id,
},
})
}
const responses = (await Promise.all([
upVote(),
upVote(),
])).map(r => r.status)
expect(responses).toContain(200)
expect(responses).toContain(400)
const c = await CommentTestUtils.getCommentById(t, comment.id)
expect(c.votes).toEqual(1)
})
})
describe('DELETE /comments/:commentId/vote', () => {
it('removes a comment vote', async () => {
let comment = await createComment()
await CommentTestUtils.upVote(t, comment.id)
comment = await CommentTestUtils.getCommentById(t, comment.id)
expect(comment.votes).toEqual(1)
await CommentTestUtils.downVote(t, comment.id)
comment = await CommentTestUtils.getCommentById(t, comment.id)
expect(comment.votes).toEqual(0)
})
it('can only downvote once', async () => {
let comment = await createComment()
await CommentTestUtils.upVote(t, comment.id)
await Promise.all([
CommentTestUtils.downVote(t, comment.id),
CommentTestUtils.downVote(t, comment.id),
])
comment = await CommentTestUtils.getCommentById(t, comment.id)
expect(comment.votes).toEqual(0)
})
})
describe('POST /comments/:commentId/spam', () => {
it('adds a new spam report', async () => {
const comment = await createComment()
await CommentTestUtils.markAsSpam(t, comment.id)
const c = await CommentTestUtils.getCommentById(t, comment.id)
expect(c.spams).toEqual(1)
})
it('can only report a spam once', async () => {
const comment = await createComment()
async function markAsSpam() {
return t.post('/comments/:commentId/spam', {
params: {
commentId: comment.id,
},
})
}
const responses = (await Promise.all([
markAsSpam(),
markAsSpam(),
])).map(r => r.status)
expect(responses).toContain(200)
expect(responses).toContain(400)
const c = await CommentTestUtils.getCommentById(t, comment.id)
expect(c.spams).toEqual(1)
})
})
describe('DELETE /comments/:commentId/spam', () => {
it('removes a spam report', async () => {
let comment = await createComment()
await CommentTestUtils.markAsSpam(t, comment.id)
comment = await CommentTestUtils.getCommentById(t, comment.id)
expect(comment.spams).toEqual(1)
await CommentTestUtils.unmarkAsSpam(t, comment.id)
comment = await CommentTestUtils.getCommentById(t, comment.id)
expect(comment.spams).toEqual(0)
})
it('can only remove a spam report once', async () => {
let comment = await createComment()
await CommentTestUtils.markAsSpam(t, comment.id)
await Promise.all([
CommentTestUtils.unmarkAsSpam(t, comment.id),
CommentTestUtils.unmarkAsSpam(t, comment.id),
])
comment = await CommentTestUtils.getCommentById(t, comment.id)
expect(comment.spams).toEqual(0)
})
})
})

View File

@ -1,91 +0,0 @@
import {AsyncRouter} from '../router'
import {BaseRoute} from '../routes/BaseRoute'
import {IAPIDef} from '@rondo/common'
import {ICommentService} from './ICommentService'
import {ensureLoggedInApi} from '../middleware'
export class CommentRoutes extends BaseRoute<IAPIDef> {
constructor(
protected readonly commentService: ICommentService,
protected readonly t: AsyncRouter<IAPIDef>,
) {
super(t)
}
setup(t: AsyncRouter<IAPIDef>) {
t.get('/stories/:storyId/comments', async req => {
const {storyId} = req.params
return this.commentService.find(storyId)
})
t.use(ensureLoggedInApi)
t.post('/stories/:storyId/comments', async req => {
const userId = req.user!.id
const storyId = Number(req.params.storyId)
const {message} = req.body
return this.commentService.saveRoot({
message,
storyId,
userId,
})
})
t.post('/stories/:storyId/comments/:parentId', async req => {
const userId = req.user!.id
const parentId = Number(req.params.parentId)
const storyId = Number(req.params.storyId)
const {message} = req.body
return this.commentService.save({
message,
userId,
parentId,
storyId,
})
})
t.get('/comments/:commentId', async req => {
const commentId = req.params.commentId
const comment = await this.commentService.findOne(commentId)
// TODO return status code 404 when not found
return comment!
})
t.put('/comments/:commentId', async req => {
const commentId = Number(req.params.commentId)
const {message} = req.body
return this.commentService.edit({
id: commentId,
message,
userId: req.user!.id,
})
})
t.delete('/comments/:commentId', async req => {
const {commentId} = req.params
return this.commentService.delete(commentId, req.user!.id)
})
t.post('/comments/:commentId/vote', async req => {
const {commentId} = req.params
return this.commentService.upVote(commentId, req.user!.id)
})
t.delete('/comments/:commentId/vote', async req => {
const {commentId} = req.params
return this.commentService.deleteVote(commentId, req.user!.id)
})
t.post('/comments/:commentId/spam', async req => {
const {commentId} = req.params
return this.commentService.markAsSpam(commentId, req.user!.id)
})
t.delete('/comments/:commentId/spam', async req => {
const {commentId} = req.params
return this.commentService.unmarkAsSpam(commentId, req.user!.id)
})
}
}

View File

@ -1,214 +0,0 @@
import {UniqueTransformer} from '../error/ErrorTransformer'
import {BaseService} from '../services/BaseService'
import {Comment} from '../entities/Comment'
import {ICommentService} from './ICommentService'
import {ICommentTree} from '@rondo/common'
import {IEditCommentParams} from './IEditCommentParams'
import {INewCommentParams} from './INewCommentParams'
import {INewRootCommentParams} from './INewRootCommentParams'
import {Spam} from '../entities/Spam'
import {Validator} from '../validator'
import {Vote} from '../entities/Vote'
export class CommentService extends BaseService implements ICommentService {
async find(storyId: number) {
const comments = await this.getRepository(Comment).find({
where: {
storyId,
},
})
const commentTree: ICommentTree = {
rootIds: [],
commentsById: {},
}
comments.reduce((obj, comment) => {
obj[comment.id] = comment
const {parentId} = comment
if (!parentId) {
commentTree.rootIds.push(comment.id)
return obj
}
const children =
obj[parentId].childrenIds =
obj[parentId].childrenIds || []
children.push(comment.id)
return obj
}, commentTree.commentsById)
return commentTree
}
async findOne(commentId: number) {
return this.getRepository(Comment).findOne(commentId)
}
async saveRoot(comment: INewRootCommentParams) {
new Validator(comment)
.ensure('message')
.ensure('storyId')
.ensure('userId')
.throw()
const {
message,
userId,
storyId,
} = comment
return this.getRepository(Comment).save({
message: message.trim(),
userId,
storyId,
parentId: undefined,
votes: 0,
spams: 0,
})
}
async save(comment: INewCommentParams) {
new Validator(comment)
.ensure('message')
.ensure('userId')
.ensure('storyId')
.ensure('parentId')
.throw()
const {
message,
userId,
storyId,
parentId,
} = comment
return this.getRepository(Comment).save({
message: message.trim(),
userId,
storyId,
parentId,
votes: 0,
spams: 0,
})
}
async edit(comment: IEditCommentParams) {
new Validator(comment)
.ensure('id')
.ensure('message')
.ensure('userId')
.throw()
const {id, message, userId} = comment
await this.getRepository(Comment)
.update({
id,
userId,
}, {
message,
})
const editedComment = await this.findOne(comment.id)
if (!editedComment) {
// TODO 400 or 404
throw new Error('Comment not found')
}
return editedComment
}
async delete(commentId: number, userId: number) {
await this.getRepository(Comment)
.update({
id: commentId,
userId,
}, {
message: '(this message has been removed)',
})
return this.findOne(commentId)
}
async upVote(commentId: number, userId: number) {
try {
await this.getRepository(Vote)
.save({
commentId,
userId,
})
} catch (err) {
UniqueTransformer.transform(err, 'Already upvoted!')
}
await this.getRepository(Comment)
.createQueryBuilder()
.update()
.where({ id: commentId })
.set({
votes: () => 'votes + 1',
})
.execute()
}
async deleteVote(commentId: number, userId: number) {
await this.getRepository(Vote)
.delete({
commentId,
userId,
})
// TODO rows.affected returns undefined or SQLite driver. This is an
// alternative query that does not depend on it.
await this.getRepository(Comment)
.createQueryBuilder()
.update()
.where({ id: commentId })
.set({
votes: () => '(select count(*) from vote where commentId = comment.id)',
})
.execute()
}
async markAsSpam(commentId: number, userId: number) {
try {
await this.getRepository(Spam)
.save({
commentId,
userId,
})
} catch (err) {
UniqueTransformer.transform(err, 'Already marked as spam!')
}
await this.getRepository(Comment)
.createQueryBuilder()
.update()
.where({ id: commentId })
.set({
spams: () => 'spams + 1',
})
.execute()
}
async unmarkAsSpam(commentId: number, userId: number) {
await this.getRepository(Spam)
.delete({
commentId,
userId,
})
// TODO rows.affected returns undefined or SQLite driver. This is an
// alternative query that does not depend on it.
await this.getRepository(Comment)
.createQueryBuilder()
.update()
.where({ id: commentId })
.set({
spams: () => '(select count(*) from spam where commentId = comment.id)',
})
.execute()
}
}

View File

@ -1,115 +0,0 @@
import {RequestTester} from '../test-utils'
import {IAPIDef} from '@rondo/common'
export async function createRootComment(
t: RequestTester<IAPIDef>,
{storyId, message}: {
storyId: number,
message: string,
},
) {
const response = await t
.post('/stories/:storyId/comments', {
params: {storyId},
})
.send({
message,
})
.expect(200)
return response.body!
}
export async function createComment(
t: RequestTester<IAPIDef>,
{storyId, parentId, message}: {
storyId: number,
parentId: number,
message: string,
},
) {
const response = await t
.post('/stories/:storyId/comments/:parentId', {
params: {storyId, parentId},
})
.send({
message,
})
.expect(200)
return response.body!
}
export async function getComments(
t: RequestTester<IAPIDef>,
storyId: number,
) {
const response = await t
.get('/stories/:storyId/comments', {
params: {storyId},
})
.expect(200)
return response.body!
}
export async function getCommentById(
t: RequestTester<IAPIDef>,
commentId: number,
) {
const response = await t
.get('/comments/:commentId', {
params: {commentId},
})
.expect(200)
return response.body!
}
export async function upVote(
t: RequestTester<IAPIDef>,
commentId: number,
) {
await t.post('/comments/:commentId/vote', {
params: {
commentId,
},
})
.expect(200)
}
export async function downVote(
t: RequestTester<IAPIDef>,
commentId: number,
) {
await t.delete('/comments/:commentId/vote', {
params: {
commentId,
},
})
.expect(200)
}
export async function markAsSpam(
t: RequestTester<IAPIDef>,
commentId: number,
) {
await t.post('/comments/:commentId/spam', {
params: {
commentId,
},
})
.expect(200)
}
export async function unmarkAsSpam(
t: RequestTester<IAPIDef>,
commentId: number,
) {
await t.delete('/comments/:commentId/spam', {
params: {
commentId,
},
})
.expect(200)
}

View File

@ -1,26 +0,0 @@
import {IComment, ICommentTree} from '@rondo/common'
import {IEditCommentParams} from './IEditCommentParams'
import {INewCommentParams} from './INewCommentParams'
import {INewRootCommentParams} from './INewRootCommentParams'
export interface ICommentService {
find(storyId: number): Promise<ICommentTree>
findOne(commentId: number): Promise<IComment | undefined>
saveRoot(comment: INewRootCommentParams): Promise<IComment>
save(comment: INewCommentParams): Promise<IComment>
edit(comment: IEditCommentParams): Promise<IComment>
delete(commentId: number, userId: number): Promise<IComment | undefined>
upVote(commentId: number, userId: number): Promise<void>
deleteVote(commentId: number, userId: number): Promise<void>
markAsSpam(commentId: number, userId: number): Promise<void>
unmarkAsSpam(commentId: number, userId: number): Promise<void>
}

View File

@ -1,5 +0,0 @@
export interface IEditCommentParams {
id: number
message: string
userId: number
}

View File

@ -1,6 +0,0 @@
export interface INewCommentParams {
message: string
userId: number
parentId: number
storyId: number
}

View File

@ -1,5 +0,0 @@
export interface INewRootCommentParams {
message: string
userId: number
storyId: number
}

View File

@ -1,3 +0,0 @@
export * from './CommentRoutes'
export * from './CommentService'
export * from './ICommentService'

View File

@ -1,32 +0,0 @@
import {Column, Entity, Index, ManyToOne} from 'typeorm'
import {User} from './User'
import {Story} from './Story'
import {BaseEntity} from './BaseEntity'
@Entity()
export class Comment extends BaseEntity {
@Column({type: 'text'})
message!: string
@ManyToOne(type => Story, story => story.comments)
story?: Story
@Column()
@Index()
storyId!: number
@ManyToOne(type => User, user => user.comments)
user?: User
@Column()
userId!: number
@Column({nullable: true})
parentId!: number
@Column()
spams!: number
@Column()
votes!: number
}

View File

@ -1,31 +0,0 @@
import {BaseEntity} from './BaseEntity'
import {Column, Entity, Index, ManyToOne, OneToMany} from 'typeorm'
import {User} from './User'
import {Story} from './Story'
import {Team} from './Team'
@Entity()
export class Site extends BaseEntity {
@Column()
name!: string
@Column({ unique: true })
domain!: string
@Index()
@Column()
userId!: number
@ManyToOne(type => User, user => user.sites)
user?: User
@Index()
@Column()
teamId!: number
@ManyToOne(type => Team, team => team.sites)
team?: Team
@OneToMany(type => Story, story => story.site)
stories!: Story[]
}

View File

@ -1,20 +0,0 @@
import {BaseEntity} from './BaseEntity'
import {Column, Entity, ManyToOne, Unique} from 'typeorm'
import {User} from './User'
import {Comment} from './Comment'
@Entity()
@Unique('spam_userid_commentid', ['userId', 'commentId'])
export class Spam extends BaseEntity {
@ManyToOne(type => User)
user?: User
@Column()
userId!: number
@ManyToOne(type => Comment)
comment?: Comment
@Column()
commentId!: number
}

View File

@ -1,19 +0,0 @@
import {BaseEntity} from './BaseEntity'
import {Column, Entity, ManyToOne} from 'typeorm'
import {Comment} from './Comment'
import {Site} from './Site'
@Entity()
export class Story extends BaseEntity {
@Column({ unique: true })
url!: string
@Column()
siteId!: number
@ManyToOne(type => Site, site => site.stories)
site?: Site
@ManyToOne(type => Comment, comment => comment.story)
comments!: Comment[]
}

View File

@ -1,6 +1,5 @@
import {BaseEntity} from './BaseEntity'
import {Column, Entity, OneToMany, ManyToOne, Index} from 'typeorm'
import {Site} from './Site'
import {UserTeam} from './UserTeam'
import {User} from './User'
@ -16,9 +15,6 @@ export class Team extends BaseEntity {
@ManyToOne(type => User)
user?: User
@OneToMany(type => Site, site => site.team)
sites!: Site[]
@OneToMany(type => UserTeam, userTeam => userTeam.team)
userTeams!: UserTeam[]
}

View File

@ -1,8 +1,6 @@
import {BaseEntity} from './BaseEntity'
import {Column, Entity, OneToMany} from 'typeorm'
import {Comment} from './Comment'
import {Session} from './Session'
import {Site} from './Site'
import {UserTeam} from './UserTeam'
import {UserEmail} from './UserEmail'
@ -18,12 +16,6 @@ export class User extends BaseEntity {
@OneToMany(type => Session, session => session.user)
sessions!: Session[]
@OneToMany(type => Site, site => site.user)
sites!: Site[]
@OneToMany(type => Comment, comment => comment.user)
comments!: Comment[]
@OneToMany(type => UserTeam, userTeam => userTeam.user)
userTeams!: UserTeam[]
}

View File

@ -1,20 +0,0 @@
import {BaseEntity} from './BaseEntity'
import {Column, Entity, ManyToOne, Unique} from 'typeorm'
import {Comment} from './Comment'
import {User} from './User'
@Entity()
@Unique('vote_userid_commentid', ['userId', 'commentId'])
export class Vote extends BaseEntity {
@ManyToOne(type => User)
user?: User
@Column()
userId!: number
@ManyToOne(type => Comment)
comment?: Comment
@Column()
commentId!: number
}

View File

@ -1,3 +1,7 @@
export * from './BaseEntity'
export * from './Role'
export * from './Session'
export * from './Team'
export * from './User'
export * from './UserEmail'
export * from './UserTeam'

View File

@ -0,0 +1,2 @@
export * from './ErrorTransformer'
export * from './TransformedError'

View File

@ -1,9 +1,13 @@
export * from './application'
export * from './config/index'
export * from './database'
export * from './entities'
export * from './error'
export * from './logger'
export * from './middleware'
export * from './router'
export * from './routes'
export * from './services'
export * from './session'
export * from './user'
export * from './validator'

View File

@ -1,2 +1,3 @@
export * from './BaseService'
export * from './IUserService'
export * from './UserService'

View File

@ -1,6 +0,0 @@
export interface ISiteCreateParams {
name: string
domain: string
teamId: number
userId: number
}

View File

@ -1,21 +0,0 @@
import {ISite} from '@rondo/common'
import {ISiteCreateParams} from './ISiteCreateParams'
import {ISiteUpdateParams} from './ISiteUpdateParams'
export interface ISiteService {
create(params: ISiteCreateParams): Promise<ISite>
update(params: ISiteUpdateParams): Promise<ISite>
remove(params: {id: number, teamId: number}): Promise<void>
findOne(id: number, teamId: number): Promise<ISite | undefined>
findByUser(userId: number): Promise<ISite[]>
findByTeam(teamId: number): Promise<ISite[]>
findByDomain(domain: string): Promise<ISite | undefined>
// TODO add other methods
}

View File

@ -1,6 +0,0 @@
export interface ISiteUpdateParams {
id: number
name?: string
domain?: string
teamId: number
}

View File

@ -1,115 +0,0 @@
import {ITeam} from '@rondo/common'
import {UserTeam} from '../entities/UserTeam'
import {createTeam} from '../team/TeamTestUtils'
import {createSite} from './SiteTestUtils'
import {test} from '../test'
describe('team', () => {
test.withDatabase()
const t = test.request('/api')
let cookie!: string
let token!: string
let team!: ITeam
let userId: number
beforeEach(async () => {
const session = await test.registerAccount()
cookie = session.cookie
token = session.token
userId = session.userId
t.setHeaders({ cookie, 'x-csrf-token': token })
team = await createTeam(t, 'test')
})
describe('POST /teams/:teamId/sites', () => {
it('creates a new site', async () => {
await createSite(t, 'test.example.com')
})
describe('no team access', () => {
beforeEach(async () => {
await test.transactionManager
.getRepository(UserTeam)
.delete({
userId,
teamId: team.id,
})
})
it('results with 403 when user does not have team access ', async () => {
await t
.post('/teams/:teamId/sites', {
params: {
teamId: team.id,
},
})
.send({
domain: 'test.example.com',
name: 'test',
})
.expect(403)
})
})
})
describe('GET /teams/:teamId/sites/:id', () => {
it('fetches a site belonging to a team', async () => {
const site = await createSite(t, 'test.example.com')
const response = await t.get('/teams/:teamId/sites/:id', {
params: {
teamId: site.teamId,
id: site.id,
},
})
.expect(200)
expect(response.body!.id).toEqual(site.id)
})
})
describe('GET /teams/:teamId/sites', () => {
it('fetches all sites belonging to a team', async () => {
const site = await createSite(t, 'test.example.com')
const response = await t.get('/teams/:teamId/sites', {
params: {
teamId: site.teamId,
},
})
expect(response.body.map(s => s.id)).toContain(site.id)
})
})
describe('PUT /teams/:teamId/sites/:id', () => {
it('updates site belonging to a team', async () => {
const site = await createSite(t, 'test.example.com')
const response = await t.put('/teams/:teamId/sites/:id', {
params: {
id: site.id,
teamId: site.teamId,
},
})
.send({
name: site.name,
domain: 'test2.example.com',
})
.expect(200)
expect(response.body.domain).toEqual('test2.example.com')
})
})
describe('DELETE /teams/:teamId/sites/:id', () => {
it('deletes a site', async () => {
const site = await createSite(t, 'test.example.com')
await t.delete('/teams/:teamId/sites/:id', {
params: {
id: site.id,
teamId: site.teamId,
},
})
.expect(200)
})
})
})

View File

@ -1,91 +0,0 @@
import {AsyncRouter} from '../router'
import {BaseRoute} from '../routes/BaseRoute'
import {IAPIDef} from '@rondo/common'
import {ISiteService} from './ISiteService'
import {ensureLoggedInApi} from '../middleware'
import {IUserPermissions} from '../user/IUserPermissions'
export class SiteRoutes extends BaseRoute<IAPIDef> {
constructor(
protected readonly siteService: ISiteService,
protected readonly permissions: IUserPermissions,
t: AsyncRouter<IAPIDef>,
) {
super(t)
}
setup(t: AsyncRouter<IAPIDef>) {
t.get('/sites/:domain', async req => {
const {domain} = req.params
return this.siteService.findByDomain(domain)
})
t.get('/teams/:teamId/sites/:id', async req => {
const {id, teamId} = req.params
return this.siteService.findOne(id, teamId)
})
t.get('/teams/:teamId/sites', async req => {
return this.siteService.findByTeam(req.params.teamId)
})
t.use(ensureLoggedInApi)
// TODO do not use this one without teamId
t.get('/my/sites', async req => {
return this.siteService.findByUser(req.user!.id)
})
t.post('/teams/:teamId/sites', async req => {
const {name, domain} = req.body
const {teamId} = req.params
await this.permissions.belongsToTeam({
teamId,
userId: req.user!.id,
})
return this.siteService.create({
name,
domain,
teamId,
userId: req.user!.id,
})
})
t.put('/teams/:teamId/sites/:id', async req => {
const {name, domain} = req.body
const id = Number(req.params.id)
const teamId = Number(req.params.teamId)
await this.permissions.belongsToTeam({
teamId,
userId: req.user!.id,
})
return this.siteService.update({
id,
teamId,
name,
domain,
})
})
t.delete('/teams/:teamId/sites/:id', async req => {
const id = Number(req.params.id)
const teamId = Number(req.params.teamId)
await this.permissions.belongsToTeam({
teamId,
userId: req.user!.id,
})
return this.siteService.remove({
id,
teamId,
})
})
}
}

View File

@ -1,66 +0,0 @@
import {BaseService} from '../services/BaseService'
import {ISiteCreateParams} from './ISiteCreateParams'
import {ISiteUpdateParams} from './ISiteUpdateParams'
import {ISiteService} from './ISiteService'
import {Site} from '../entities/Site'
export class SiteService extends BaseService implements ISiteService {
async create(params: ISiteCreateParams) {
// TODO validate params.domain
// TODO check site limit per user
return this.getRepository(Site).save(params)
}
async update({teamId, id, name, domain}: ISiteUpdateParams) {
// TODO validate params.domain
await this.getRepository(Site)
.update({
id,
teamId,
}, {
name,
domain,
})
return (await this.findOne(id, teamId))!
}
async remove({id, teamId}: {id: number, teamId: number}) {
await this.getRepository(Site)
.delete({
id,
teamId,
})
}
async findOne(id: number, teamId: number) {
return this.getRepository(Site).findOne({
where: {
id,
teamId,
},
})
}
async findByDomain(domain: string) {
return this.getRepository(Site).findOne({
where: {
domain,
},
})
}
async findByUser(userId: number) {
return this.getRepository(Site).find({
where: { userId },
})
}
async findByTeam(teamId: number) {
return this.getRepository(Site).find({
where: { teamId },
})
}
}

View File

@ -1,20 +0,0 @@
import {RequestTester} from '../test-utils'
import {IAPIDef} from '@rondo/common'
import {createTeam} from '../team/TeamTestUtils'
export async function createSite(t: RequestTester<IAPIDef>, domain: string) {
const team = await createTeam(t, 'test')
const response = await t
.post('/teams/:teamId/sites', {
params: {
teamId: team.id,
},
})
.send({
domain,
name: 'test-site',
})
.expect(200)
expect(response.body.id).toBeTruthy()
return response.body
}

View File

@ -1,3 +0,0 @@
export * from './ISiteService'
export * from './SiteRoutes'
export * from './SiteService'

View File

@ -1,7 +0,0 @@
import {Story} from '../entities/Story'
export interface IStoryService {
findOne(url: string): Promise<Story | undefined>
// TODO add other methods
}

View File

@ -1,58 +0,0 @@
import {ISite} from '@rondo/common'
import {createSite} from '../site/SiteTestUtils'
import {getStory} from './StoryTestUtils'
import {test} from '../test'
describe('story', () => {
test.withDatabase()
const t = test.request('/api')
let cookie!: string
let token!: string
let site!: ISite
beforeEach(async () => {
const session = await test.registerAccount()
cookie = session.cookie
token = session.token
t.setHeaders({ cookie, 'x-csrf-token': token })
site = await createSite(t, 'test.example.com')
})
const invalidUrl = 'https://invalid.example.com/test'
const validUrl = 'https://test.example.com/test'
describe('/stories/by-url', () => {
it('returns undefined when a site is not configured', async () => {
const response = await t
.get('/stories/by-url', {
query: { url: invalidUrl },
})
.expect(200)
expect(response.body).toEqual('')
})
it('creates a story when it does not exist', async () => {
const story = await getStory(t, validUrl)
expect(story.id).toBeTruthy()
expect(story.siteId).toEqual(site.id)
})
it('retrieves existing story after it is created', async () => {
const story1 = await getStory(t, validUrl)
const story2 = await getStory(t, validUrl)
expect(story1.id).toBeTruthy()
expect(story1).toEqual(story2)
})
it('prevents unique exceptions', async () => {
const p1 = getStory(t, validUrl)
const p2 = getStory(t, validUrl)
const [story1, story2] = await Promise.all([p1, p2])
expect(story1.id).toBeTruthy()
expect(story1).toEqual(story2)
})
})
})

View File

@ -1,23 +0,0 @@
import {AsyncRouter} from '../router'
import {BaseRoute} from '../routes/BaseRoute'
import {IAPIDef} from '@rondo/common'
import {IStoryService} from './IStoryService'
export class StoryRoutes extends BaseRoute<IAPIDef> {
constructor(
protected readonly storyService: IStoryService,
protected readonly t: AsyncRouter<IAPIDef>,
) {
super(t)
}
setup(t: AsyncRouter<IAPIDef>) {
t.get('/stories/by-url', async req => {
const {url} = req.query
return this.storyService.findOne(url)
})
}
}

View File

@ -1,50 +0,0 @@
import URL from 'url'
import {BaseService} from '../services/BaseService'
import {ISiteService} from '../site/ISiteService'
import {IStoryService} from './IStoryService'
import {ITransactionManager} from '../database/ITransactionManager'
import {Story} from '../entities/Story'
import {UniqueTransformer} from '../error/ErrorTransformer'
export class StoryService extends BaseService implements IStoryService {
constructor(
transactionManager: ITransactionManager,
protected readonly siteService: ISiteService,
) {
super(transactionManager)
}
async findOne(url: string) {
const storyRepo = this.getRepository(Story)
const story = await storyRepo.findOne({
where: {url},
})
if (story) {
return story
}
const domain = URL.parse(url).hostname
const site = await this.siteService.findByDomain(domain!)
if (!site) {
return undefined
}
try {
return await storyRepo.save({
url,
siteId: site.id,
})
} catch (err) {
// throw if not a unique constraint error
UniqueTransformer.throwIfNotMatch(err)
// This could happen if there are two concurrent requests coming in at
// the same time, and they both cannot find the story, then decide to
// create a record.
return await storyRepo.findOne({
where: {url},
})
}
}
}

View File

@ -1,12 +0,0 @@
import {IAPIDef} from '@rondo/common'
import {RequestTester} from '../test-utils'
export async function getStory(t: RequestTester<IAPIDef>, url: string) {
const response = await t
.get('/stories/by-url', {
query: {url},
})
.expect(200)
return response.body!
}

View File

@ -1,3 +0,0 @@
export * from './StoryRoutes'
export * from './IStoryService'
export * from './StoryService'