ソースを参照

add integration tests (#11)

* refactor orion to be testable

* add tests

* add tests workflow on PR
Klaudiusz Dembler 4 年 前
コミット
50cabc99b3

+ 37 - 0
.eslintrc.js

@@ -2,5 +2,42 @@ module.exports = {
   extends: ['@joystream/eslint-config'],
   rules: {
     '@typescript-eslint/explicit-module-boundary-types': 'off',
+    '@typescript-eslint/naming-convention': [
+      'error',
+      {
+        selector: 'default',
+        format: ['camelCase'],
+      },
+      {
+        selector: 'variable',
+        format: ['camelCase', 'UPPER_CASE', 'PascalCase'],
+      },
+      {
+        selector: 'property',
+        format: [], // Don't force format of object properties, so they can be ie.: { "Some thing": 123 }, { some_thing: 123 } etc.
+      },
+      {
+        selector: 'accessor',
+        format: ['camelCase', 'snake_case'],
+      },
+      {
+        selector: 'enumMember',
+        format: ['PascalCase'],
+      },
+      {
+        selector: 'typeLike',
+        format: [],
+        custom: { regex: '^([A-Z][a-z0-9]*_?)+', match: true }, // combined PascalCase and snake_case to allow ie. OpeningType_Worker
+      },
+      {
+        selector: 'classMethod',
+        modifiers: ['static'],
+        format: ['PascalCase'],
+      },
+    ],
+  },
+  plugins: ['jest'],
+  env: {
+    'jest/globals': true,
   },
 }

+ 22 - 0
.github/workflows/tests.yml

@@ -0,0 +1,22 @@
+name: Tests
+on: [push, pull_request]
+
+jobs:
+  lint:
+    name: Tests
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ubuntu-latest]
+        node-version: [12.x]
+      fail-fast: true
+    steps:
+      - uses: actions/checkout@v2
+      - name: Use Node.js ${{matrix.node-version}}
+        uses: actions/setup-node@v1
+        with:
+          node-version: ${{matrix.node-version}}
+      - name: Install modules
+        run: yarn install --frozen-lockfile
+      - name: Run Jest
+        run: yarn test

+ 2 - 0
.gitignore

@@ -3,3 +3,5 @@ yarn-error.log*
 
 node_modules/
 dist/
+
+globalConfig.json

+ 12 - 0
jest-mongodb-config.js

@@ -0,0 +1,12 @@
+module.exports = {
+  mongodbMemoryServerOptions: {
+    binary: {
+      version: '4.4.2',
+      skipMD5: true,
+    },
+    autoStart: false,
+    instance: {
+      dbName: 'jest',
+    },
+  },
+}

+ 9 - 0
jest.config.js

@@ -0,0 +1,9 @@
+/* eslint-disable @typescript-eslint/no-var-requires */
+
+const { defaults: tsjPreset } = require('ts-jest/presets')
+
+module.exports = {
+  preset: '@shelf/jest-mongodb',
+  transform: tsjPreset.transform,
+  setupFiles: ['./tests/setup.ts'],
+}

+ 14 - 6
package.json

@@ -18,10 +18,11 @@
   },
   "scripts": {
     "clean": "rm -rf dist",
-    "dev": "NODE_ENV=development ts-node-dev --respawn src/server.ts",
-    "build": "yarn clean && yarn tsc",
-    "start": "node dist/server.js",
-    "lint": "eslint . --ext .ts"
+    "dev": "NODE_ENV=development ts-node-dev --respawn src/main.ts",
+    "build": "yarn clean && yarn tsc -p tsconfig.build.json",
+    "start": "node dist/main.js",
+    "lint": "eslint . --ext .ts",
+    "test": "jest"
   },
   "lint-staged": {
     "*.{ts,json}": [
@@ -31,11 +32,12 @@
   "dependencies": {
     "@typegoose/auto-increment": "^0.6.0",
     "@typegoose/typegoose": "^7.4.1",
-    "apollo-server-express": "^2.18.1",
+    "apollo-server-express": "^2.19.1",
     "class-validator": "^0.12.2",
     "dotenv": "^8.2.0",
     "express": "^4.17.1",
-    "graphql": "^15.3.0",
+    "graphql": "15",
+    "mongodb": "^3.6.3",
     "mongoose": "^5.10.7",
     "reflect-metadata": "^0.1.13",
     "type-graphql": "^1.0.0"
@@ -43,15 +45,21 @@
   "devDependencies": {
     "@joystream/eslint-config": "^1.0.0",
     "@joystream/prettier-config": "^1.0.0",
+    "@shelf/jest-mongodb": "^1.2.3",
     "@types/express": "^4.17.8",
+    "@types/jest": "^26.0.19",
     "@types/mongoose": "^5.7.36",
     "@types/node": "^14.11.2",
     "@typescript-eslint/eslint-plugin": "^4.10.0",
     "@typescript-eslint/parser": "^4.10.0",
+    "apollo-server-testing": "^2.19.1",
     "eslint": "^7.10.0",
+    "eslint-plugin-jest": "^24.1.3",
     "husky": "^4.3.0",
+    "jest": "^26.6.3",
     "lint-staged": "^10.4.0",
     "prettier": "^2.1.2",
+    "ts-jest": "^26.4.4",
     "ts-node": "^9.0.0",
     "ts-node-dev": "^1.0.0-pre.63",
     "typescript": "^4.0.3"

+ 9 - 5
src/aggregates/follows.ts

@@ -10,14 +10,18 @@ type ChannelEventsAggregationResult = {
   events?: ChannelEvent[]
 }[]
 
-export class FollowsAggregate extends GenericAggregate<ChannelEvent> {
+export class FollowsAggregate implements GenericAggregate<ChannelEvent> {
   private channelFollowsMap: Record<string, number> = {}
 
   public channelFollows(channelId: string): number | null {
     return this.channelFollowsMap[channelId] ?? null
   }
 
-  public async rebuild() {
+  public getChannelFollowsMap() {
+    return Object.freeze(this.channelFollowsMap)
+  }
+
+  public static async Build(): Promise<FollowsAggregate> {
     const aggregation: ChannelEventsAggregationResult = await ChannelEventsBucketModel.aggregate([
       { $unwind: '$events' },
       { $group: { _id: null, allEvents: { $push: '$events' } } },
@@ -26,9 +30,11 @@ export class FollowsAggregate extends GenericAggregate<ChannelEvent> {
 
     const events = aggregation[0]?.events || []
 
+    const aggregate = new FollowsAggregate()
     events.forEach((event) => {
-      this.applyEvent(event)
+      aggregate.applyEvent(event)
     })
+    return aggregate
   }
 
   public applyEvent(event: UnsequencedChannelEvent) {
@@ -46,5 +52,3 @@ export class FollowsAggregate extends GenericAggregate<ChannelEvent> {
     }
   }
 }
-
-export const followsAggregate = new FollowsAggregate()

+ 3 - 3
src/aggregates/index.ts

@@ -1,4 +1,4 @@
-import { followsAggregate } from './follows'
-import { viewsAggregate } from './views'
+import { FollowsAggregate } from './follows'
+import { ViewsAggregate } from './views'
 
-export { followsAggregate, viewsAggregate }
+export { FollowsAggregate, ViewsAggregate }

+ 2 - 3
src/aggregates/shared.ts

@@ -1,6 +1,5 @@
 import { GenericEvent } from '../models/shared'
 
-export abstract class GenericAggregate<EventType = GenericEvent> {
-  public abstract rebuild(): Promise<void>
-  public abstract applyEvent(event: EventType): void
+export interface GenericAggregate<EventType = GenericEvent> {
+  applyEvent: (event: EventType) => void
 }

+ 12 - 4
src/aggregates/views.ts

@@ -16,7 +16,15 @@ export class ViewsAggregate {
     return this.channelViewsMap[channelId] ?? null
   }
 
-  public async rebuild() {
+  public getVideoViewsMap() {
+    return Object.freeze(this.videoViewsMap)
+  }
+
+  public getChannelViewsMap() {
+    return Object.freeze(this.channelViewsMap)
+  }
+
+  public static async Build() {
     const aggregation: VideoEventsAggregationResult = await VideoEventsBucketModel.aggregate([
       { $unwind: '$events' },
       { $group: { _id: null, allEvents: { $push: '$events' } } },
@@ -25,9 +33,11 @@ export class ViewsAggregate {
 
     const events = aggregation[0]?.events || []
 
+    const aggregate = new ViewsAggregate()
     events.forEach((event) => {
-      this.applyEvent(event)
+      aggregate.applyEvent(event)
     })
+    return aggregate
   }
 
   public applyEvent(event: UnsequencedVideoEvent) {
@@ -44,5 +54,3 @@ export class ViewsAggregate {
     }
   }
 }
-
-export const viewsAggregate = new ViewsAggregate()

+ 32 - 11
src/config.ts

@@ -1,7 +1,5 @@
 import dotenv from 'dotenv'
 
-dotenv.config()
-
 const isDev = process.env.NODE_ENV === 'development'
 
 type LoadEnvVarOpts = {
@@ -25,16 +23,39 @@ const loadEnvVar = (name: string, { defaultValue, devDefaultValue }: LoadEnvVarO
   throw new Error(`Required env variable "${name}" is missing from the environment`)
 }
 
-const rawPort = loadEnvVar('ORION_PORT', { defaultValue: '6116' })
-const port = parseInt(rawPort)
+export class Config {
+  private _port: number
+  private _bucketSize: number
+  private _mongoDBUri: string
+
+  get port(): number {
+    return this._port
+  }
+
+  get bucketSize(): number {
+    return this._bucketSize
+  }
 
-const mongoHostname = loadEnvVar('ORION_MONGO_HOSTNAME', { devDefaultValue: 'localhost' })
-const rawMongoPort = loadEnvVar('ORION_MONGO_PORT', { defaultValue: '27017' })
-const mongoDatabase = loadEnvVar('ORION_MONGO_DATABASE', { defaultValue: 'orion' })
+  get mongoDBUri(): string {
+    return this._mongoDBUri
+  }
 
-const mongoDBUri = `mongodb://${mongoHostname}:${rawMongoPort}/${mongoDatabase}`
+  loadConfig() {
+    dotenv.config()
 
-export default {
-  port,
-  mongoDBUri,
+    const rawPort = loadEnvVar('ORION_PORT', { defaultValue: '6116' })
+    this._port = parseInt(rawPort)
+
+    const rawBucketSize = loadEnvVar('ORION_BUCKET_SIZE', { defaultValue: '50000' })
+    this._bucketSize = parseInt(rawBucketSize)
+
+    const mongoHostname = loadEnvVar('ORION_MONGO_HOSTNAME', { devDefaultValue: 'localhost' })
+    const rawMongoPort = loadEnvVar('ORION_MONGO_PORT', { defaultValue: '27017' })
+    const mongoDatabase = loadEnvVar('ORION_MONGO_DATABASE', { defaultValue: 'orion' })
+
+    this._mongoDBUri = `mongodb://${mongoHostname}:${rawMongoPort}/${mongoDatabase}`
+  }
 }
+
+const config = new Config()
+export default config

+ 35 - 0
src/main.ts

@@ -0,0 +1,35 @@
+import config from './config'
+import Express from 'express'
+import { buildAggregates, connectMongoose, createServer } from './server'
+
+const main = async () => {
+  config.loadConfig()
+
+  const mongoose = await wrapTask(`Connecting to MongoDB at "${config.mongoDBUri}"`, () =>
+    connectMongoose(config.mongoDBUri)
+  )
+
+  const aggregates = await wrapTask('Rebuilding aggregates', buildAggregates)
+
+  const server = await createServer(mongoose, aggregates)
+  const app = Express()
+  server.applyMiddleware({ app })
+  app.listen({ port: config.port }, () =>
+    console.log(`🚀 Server listening at ==> http://localhost:${config.port}${server.graphqlPath}`)
+  )
+}
+
+const wrapTask = async <T>(message: string, task: () => Promise<T>): Promise<T> => {
+  process.stdout.write(`${message}...`)
+  try {
+    const result = await task()
+    process.stdout.write(' Done.\n')
+    return result
+  } catch (error) {
+    process.stdout.write(' Failed!\n')
+    console.error(error)
+    process.exit()
+  }
+}
+
+main()

+ 2 - 3
src/models/shared.ts

@@ -1,6 +1,5 @@
 import { DocumentType, prop, ReturnModelType } from '@typegoose/typegoose'
-
-const MAX_BUCKET_SIZE = 50000
+import config from '../config'
 
 export class GenericEvent {
   @prop()
@@ -41,7 +40,7 @@ export const insertEventIntoBucket = async (
     _id: lastEventId + 1,
   }
 
-  if (!lastBucket || lastBucket.size >= MAX_BUCKET_SIZE) {
+  if (!lastBucket || lastBucket.size >= config.bucketSize) {
     return await createNewBucketFromEvent(lastBucket, event, bucketModel)
   }
 

+ 18 - 14
src/resolvers/followsInfo.ts

@@ -1,7 +1,7 @@
-import { Args, ArgsType, Field, ID, Mutation, Query, Resolver } from 'type-graphql'
-import { followsAggregate } from '../aggregates'
+import { Args, ArgsType, Ctx, Field, ID, Mutation, Query, Resolver } from 'type-graphql'
 import { ChannelFollowsInfo } from '../entities/ChannelFollowsInfo'
 import { ChannelEventType, saveChannelEvent, UnsequencedChannelEvent } from '../models/ChannelEvent'
+import { Context } from '../types'
 
 @ArgsType()
 class ChannelFollowsArgs {
@@ -24,19 +24,23 @@ class UnfollowChannelArgs extends ChannelFollowsArgs {}
 @Resolver()
 export class ChannelFollowsInfosResolver {
   @Query(() => ChannelFollowsInfo, { nullable: true, description: 'Get follows count for a single channel' })
-  async channelFollows(@Args() { channelId }: ChannelFollowsArgs): Promise<ChannelFollowsInfo | null> {
-    return getFollowsInfo(channelId)
+  async channelFollows(
+    @Args() { channelId }: ChannelFollowsArgs,
+    @Ctx() ctx: Context
+  ): Promise<ChannelFollowsInfo | null> {
+    return getFollowsInfo(channelId, ctx)
   }
 
   @Query(() => [ChannelFollowsInfo], { description: 'Get follows counts for a list of channels', nullable: 'items' })
   async batchedChannelFollows(
-    @Args() { channelIdList }: BatchedChannelFollowsArgs
+    @Args() { channelIdList }: BatchedChannelFollowsArgs,
+    @Ctx() ctx: Context
   ): Promise<(ChannelFollowsInfo | null)[]> {
-    return channelIdList.map((channelId) => getFollowsInfo(channelId))
+    return channelIdList.map((channelId) => getFollowsInfo(channelId, ctx))
   }
 
   @Mutation(() => ChannelFollowsInfo, { description: 'Add a single follow to the target channel' })
-  async followChannel(@Args() { channelId }: FollowChannelArgs): Promise<ChannelFollowsInfo> {
+  async followChannel(@Args() { channelId }: FollowChannelArgs, @Ctx() ctx: Context): Promise<ChannelFollowsInfo> {
     const event: UnsequencedChannelEvent = {
       channelId,
       type: ChannelEventType.FollowChannel,
@@ -44,13 +48,13 @@ export class ChannelFollowsInfosResolver {
     }
 
     await saveChannelEvent(event)
-    followsAggregate.applyEvent(event)
+    ctx.followsAggregate.applyEvent(event)
 
-    return getFollowsInfo(channelId)!
+    return getFollowsInfo(channelId, ctx)!
   }
 
   @Mutation(() => ChannelFollowsInfo, { description: 'Remove a single follow from the target channel' })
-  async unfollowChannel(@Args() { channelId }: UnfollowChannelArgs): Promise<ChannelFollowsInfo> {
+  async unfollowChannel(@Args() { channelId }: UnfollowChannelArgs, @Ctx() ctx: Context): Promise<ChannelFollowsInfo> {
     const event: UnsequencedChannelEvent = {
       channelId,
       type: ChannelEventType.UnfollowChannel,
@@ -58,14 +62,14 @@ export class ChannelFollowsInfosResolver {
     }
 
     await saveChannelEvent(event)
-    followsAggregate.applyEvent(event)
+    ctx.followsAggregate.applyEvent(event)
 
-    return getFollowsInfo(channelId)!
+    return getFollowsInfo(channelId, ctx)!
   }
 }
 
-const getFollowsInfo = (channelId: string): ChannelFollowsInfo | null => {
-  const follows = followsAggregate.channelFollows(channelId)
+const getFollowsInfo = (channelId: string, ctx: Context): ChannelFollowsInfo | null => {
+  const follows = ctx.followsAggregate.channelFollows(channelId)
   if (follows != null) {
     return {
       id: channelId,

+ 23 - 17
src/resolvers/viewsInfo.ts

@@ -1,7 +1,7 @@
-import { Args, ArgsType, Field, ID, Mutation, Query, Resolver } from 'type-graphql'
+import { Args, ArgsType, Ctx, Field, ID, Mutation, Query, Resolver } from 'type-graphql'
 import { EntityViewsInfo } from '../entities/EntityViewsInfo'
-import { viewsAggregate } from '../aggregates'
 import { saveVideoEvent, VideoEventType, UnsequencedVideoEvent } from '../models/VideoEvent'
+import { Context } from '../types'
 
 @ArgsType()
 class VideoViewsArgs {
@@ -39,27 +39,33 @@ class AddVideoViewArgs {
 @Resolver()
 export class VideoViewsInfosResolver {
   @Query(() => EntityViewsInfo, { nullable: true, description: 'Get views count for a single video' })
-  async videoViews(@Args() { videoId }: VideoViewsArgs): Promise<EntityViewsInfo | null> {
-    return getVideoViewsInfo(videoId)
+  async videoViews(@Args() { videoId }: VideoViewsArgs, @Ctx() ctx: Context): Promise<EntityViewsInfo | null> {
+    return getVideoViewsInfo(videoId, ctx)
   }
 
   @Query(() => [EntityViewsInfo], { description: 'Get views counts for a list of videos', nullable: 'items' })
-  async batchedVideoViews(@Args() { videoIdList }: BatchedVideoViewsArgs): Promise<(EntityViewsInfo | null)[]> {
-    return videoIdList.map((videoId) => getVideoViewsInfo(videoId))
+  async batchedVideoViews(
+    @Args() { videoIdList }: BatchedVideoViewsArgs,
+    @Ctx() ctx: Context
+  ): Promise<(EntityViewsInfo | null)[]> {
+    return videoIdList.map((videoId) => getVideoViewsInfo(videoId, ctx))
   }
 
   @Query(() => EntityViewsInfo, { nullable: true, description: 'Get views count for a single channel' })
-  async channelViews(@Args() { channelId }: ChannelViewsArgs): Promise<EntityViewsInfo | null> {
-    return getChannelViewsInfo(channelId)
+  async channelViews(@Args() { channelId }: ChannelViewsArgs, @Ctx() ctx: Context): Promise<EntityViewsInfo | null> {
+    return getChannelViewsInfo(channelId, ctx)
   }
 
   @Query(() => [EntityViewsInfo], { description: 'Get views counts for a list of channels', nullable: 'items' })
-  async batchedChannelsViews(@Args() { channelIdList }: BatchedChannelViewsArgs): Promise<(EntityViewsInfo | null)[]> {
-    return channelIdList.map((channelId) => getChannelViewsInfo(channelId))
+  async batchedChannelsViews(
+    @Args() { channelIdList }: BatchedChannelViewsArgs,
+    @Ctx() ctx: Context
+  ): Promise<(EntityViewsInfo | null)[]> {
+    return channelIdList.map((channelId) => getChannelViewsInfo(channelId, ctx))
   }
 
   @Mutation(() => EntityViewsInfo, { description: "Add a single view to the target video's count" })
-  async addVideoView(@Args() { videoId, channelId }: AddVideoViewArgs): Promise<EntityViewsInfo> {
+  async addVideoView(@Args() { videoId, channelId }: AddVideoViewArgs, @Ctx() ctx: Context): Promise<EntityViewsInfo> {
     const event: UnsequencedVideoEvent = {
       videoId,
       channelId,
@@ -68,9 +74,9 @@ export class VideoViewsInfosResolver {
     }
 
     await saveVideoEvent(event)
-    viewsAggregate.applyEvent(event)
+    ctx.viewsAggregate.applyEvent(event)
 
-    return getVideoViewsInfo(videoId)!
+    return getVideoViewsInfo(videoId, ctx)!
   }
 }
 
@@ -84,12 +90,12 @@ const buildViewsObject = (id: string, views: number | null): EntityViewsInfo | n
   return null
 }
 
-const getVideoViewsInfo = (videoId: string): EntityViewsInfo | null => {
-  const views = viewsAggregate.videoViews(videoId)
+const getVideoViewsInfo = (videoId: string, ctx: Context): EntityViewsInfo | null => {
+  const views = ctx.viewsAggregate.videoViews(videoId)
   return buildViewsObject(videoId, views)
 }
 
-const getChannelViewsInfo = (channelId: string): EntityViewsInfo | null => {
-  const views = viewsAggregate.channelViews(channelId)
+const getChannelViewsInfo = (channelId: string, ctx: Context): EntityViewsInfo | null => {
+  const views = ctx.viewsAggregate.channelViews(channelId)
   return buildViewsObject(channelId, views)
 }

+ 22 - 48
src/server.ts

@@ -1,16 +1,14 @@
 import 'reflect-metadata'
 import { ApolloServer } from 'apollo-server-express'
-import Express from 'express'
-import { connect } from 'mongoose'
+import { connect, Mongoose } from 'mongoose'
 import { buildSchema } from 'type-graphql'
 
-import config from './config'
-import { followsAggregate, viewsAggregate } from './aggregates'
+import { FollowsAggregate, ViewsAggregate } from './aggregates'
 import { ChannelFollowsInfosResolver, VideoViewsInfosResolver } from './resolvers'
+import { Aggregates, Context } from './types'
 
-const main = async () => {
-  await getMongooseConnection()
-  await rebuildAggregates()
+export const createServer = async (mongoose: Mongoose, aggregates: Aggregates) => {
+  await mongoose.connection
 
   const schema = await buildSchema({
     resolvers: [VideoViewsInfosResolver, ChannelFollowsInfosResolver],
@@ -18,50 +16,26 @@ const main = async () => {
     validate: false,
   })
 
-  const server = new ApolloServer({ schema })
-  const app = Express()
-  server.applyMiddleware({ app })
-  app.listen({ port: config.port }, () =>
-    console.log(`🚀 Server listening at ==> http://localhost:${config.port}${server.graphqlPath}`)
-  )
-}
-
-const getMongooseConnection = async () => {
-  const task = async () => {
-    const mongoose = await connect(config.mongoDBUri, {
-      useUnifiedTopology: true,
-      useNewUrlParser: true,
-      useCreateIndex: true,
-    })
-    await mongoose.connection
-  }
-  await wrapTask(`Connecting to MongoDB at "${config.mongoDBUri}"`, task)
-}
-
-const rebuildAggregates = async () => {
-  const viewEventsTask = async () => {
-    await viewsAggregate.rebuild()
+  const context: Context = {
+    ...aggregates,
   }
 
-  const followEventsTask = async () => {
-    await followsAggregate.rebuild()
-  }
-
-  await wrapTask('Rebuiliding view events aggregate', viewEventsTask)
-  await wrapTask('Rebuiliding follow events aggregate', followEventsTask)
+  return new ApolloServer({ schema, context })
 }
 
-const wrapTask = async (message: string, task: () => Promise<void>) => {
-  process.stdout.write(`${message}...`)
-  try {
-    await task()
-  } catch (error) {
-    process.stdout.write(' Failed!\n')
-    console.error(error)
-    process.exit()
-    return
-  }
-  process.stdout.write(' Done.\n')
+export const connectMongoose = async (connectionUri: string) => {
+  const mongoose = await connect(connectionUri, {
+    useUnifiedTopology: true,
+    useNewUrlParser: true,
+    useCreateIndex: true,
+  })
+  await mongoose.connection
+  return mongoose
 }
 
-main()
+export const buildAggregates = async (): Promise<Aggregates> => {
+  const viewsAggregate = await ViewsAggregate.Build()
+  const followsAggregate = await FollowsAggregate.Build()
+
+  return { viewsAggregate, followsAggregate }
+}

+ 8 - 0
src/types.d.ts

@@ -0,0 +1,8 @@
+import { FollowsAggregate, ViewsAggregate } from './aggregates'
+
+export type Aggregates = {
+  viewsAggregate: ViewsAggregate
+  followsAggregate: FollowsAggregate
+}
+
+export type Context = Aggregates

+ 225 - 0
tests/follows.test.ts

@@ -0,0 +1,225 @@
+import { ApolloServer } from 'apollo-server-express'
+import { Mongoose } from 'mongoose'
+import { Aggregates } from '../src/types'
+import { ApolloServerTestClient } from 'apollo-server-testing/dist/createTestClient'
+import { buildAggregates, connectMongoose, createServer } from '../src/server'
+import { createTestClient } from 'apollo-server-testing'
+import {
+  FOLLOW_CHANNEL,
+  FollowChannel,
+  FollowChannelArgs,
+  GET_CHANNEL_FOLLOWS,
+  GetChannelFollows,
+  GetChannelFollowsArgs,
+  UNFOLLOW_CHANNEL,
+  UnfollowChannel,
+  UnfollowChannelArgs,
+} from './queries/follows'
+import { ChannelFollowsInfo } from '../src/entities/ChannelFollowsInfo'
+import { ChannelEventsBucketModel } from '../src/models/ChannelEvent'
+import { TEST_BUCKET_SIZE } from './setup'
+
+const FIRST_CHANNEL_ID = '22'
+const SECOND_CHANNEL_ID = '23'
+
+describe('Channel follows resolver', () => {
+  let server: ApolloServer
+  let mongoose: Mongoose
+  let aggregates: Aggregates
+  let query: ApolloServerTestClient['query']
+  let mutate: ApolloServerTestClient['mutate']
+
+  beforeEach(async () => {
+    mongoose = await connectMongoose(process.env.MONGO_URL!)
+    aggregates = await buildAggregates()
+    server = await createServer(mongoose, aggregates)
+    const testClient = createTestClient(server)
+    query = testClient.query
+    mutate = testClient.mutate
+  })
+
+  afterEach(async () => {
+    await server.stop()
+    await ChannelEventsBucketModel.deleteMany({})
+    await mongoose.disconnect()
+  })
+
+  const followChannel = async (channelId: string) => {
+    const followChannelResponse = await mutate<FollowChannel, FollowChannelArgs>({
+      mutation: FOLLOW_CHANNEL,
+      variables: { channelId },
+    })
+    expect(followChannelResponse.errors).toBeUndefined()
+    return followChannelResponse.data?.followChannel
+  }
+
+  const unfollowChannel = async (channelId: string) => {
+    const unfollowChannelResponse = await mutate<UnfollowChannel, UnfollowChannelArgs>({
+      mutation: UNFOLLOW_CHANNEL,
+      variables: { channelId },
+    })
+    expect(unfollowChannelResponse.errors).toBeUndefined()
+    return unfollowChannelResponse.data?.unfollowChannel
+  }
+
+  const getChannelFollows = async (channelId: string) => {
+    const channelFollowsResponse = await query<GetChannelFollows, GetChannelFollowsArgs>({
+      query: GET_CHANNEL_FOLLOWS,
+      variables: { channelId },
+    })
+    expect(channelFollowsResponse.errors).toBeUndefined()
+    return channelFollowsResponse.data?.channelFollows
+  }
+
+  it('should return null for unknown channel follows', async () => {
+    const channelFollows = await getChannelFollows(FIRST_CHANNEL_ID)
+
+    expect(channelFollows).toBeNull()
+  })
+
+  it('should properly handle channel follow', async () => {
+    const expectedChannelFollows: ChannelFollowsInfo = {
+      id: FIRST_CHANNEL_ID,
+      follows: 1,
+    }
+
+    let addChannelFollowData = await followChannel(FIRST_CHANNEL_ID)
+    expect(addChannelFollowData).toEqual(expectedChannelFollows)
+
+    let channelFollows = await getChannelFollows(FIRST_CHANNEL_ID)
+    expect(channelFollows).toEqual(expectedChannelFollows)
+
+    expectedChannelFollows.follows++
+
+    addChannelFollowData = await followChannel(FIRST_CHANNEL_ID)
+    expect(addChannelFollowData).toEqual(expectedChannelFollows)
+
+    channelFollows = await getChannelFollows(FIRST_CHANNEL_ID)
+    expect(channelFollows).toEqual(expectedChannelFollows)
+  })
+
+  it('should properly handle channel unfollow', async () => {
+    const expectedChannelFollows: ChannelFollowsInfo = {
+      id: FIRST_CHANNEL_ID,
+      follows: 5,
+    }
+
+    await followChannel(FIRST_CHANNEL_ID)
+    await followChannel(FIRST_CHANNEL_ID)
+    await followChannel(FIRST_CHANNEL_ID)
+    await followChannel(FIRST_CHANNEL_ID)
+    await followChannel(FIRST_CHANNEL_ID)
+
+    let channelFollows = await getChannelFollows(FIRST_CHANNEL_ID)
+    expect(channelFollows).toEqual(expectedChannelFollows)
+
+    expectedChannelFollows.follows--
+
+    const unfollowChannelData = await unfollowChannel(FIRST_CHANNEL_ID)
+    expect(unfollowChannelData).toEqual(expectedChannelFollows)
+
+    channelFollows = await getChannelFollows(FIRST_CHANNEL_ID)
+    expect(channelFollows).toEqual(expectedChannelFollows)
+  })
+
+  it('should keep the channel follows non-negative', async () => {
+    const expectedChannelFollows: ChannelFollowsInfo = {
+      id: FIRST_CHANNEL_ID,
+      follows: 0,
+    }
+
+    await followChannel(FIRST_CHANNEL_ID)
+    await followChannel(FIRST_CHANNEL_ID)
+
+    await unfollowChannel(FIRST_CHANNEL_ID)
+    await unfollowChannel(FIRST_CHANNEL_ID)
+    await unfollowChannel(FIRST_CHANNEL_ID)
+
+    const channelFollows = await getChannelFollows(FIRST_CHANNEL_ID)
+    expect(channelFollows).toEqual(expectedChannelFollows)
+  })
+
+  it('should distinct follows of separate channels', async () => {
+    const expectedFirstChannelFollows: ChannelFollowsInfo = {
+      id: FIRST_CHANNEL_ID,
+      follows: 1,
+    }
+    const expectedSecondChannelFollows: ChannelFollowsInfo = {
+      id: SECOND_CHANNEL_ID,
+      follows: 1,
+    }
+
+    const firstChannelFollowData = await followChannel(FIRST_CHANNEL_ID)
+    const secondChannelFollowData = await followChannel(SECOND_CHANNEL_ID)
+
+    expect(firstChannelFollowData).toEqual(expectedFirstChannelFollows)
+    expect(secondChannelFollowData).toEqual(expectedSecondChannelFollows)
+
+    expectedFirstChannelFollows.follows++
+
+    await followChannel(FIRST_CHANNEL_ID)
+
+    const firstChannelFollows = await getChannelFollows(FIRST_CHANNEL_ID)
+    const secondChannelFollows = await getChannelFollows(SECOND_CHANNEL_ID)
+
+    expect(firstChannelFollows).toEqual(expectedFirstChannelFollows)
+    expect(secondChannelFollows).toEqual(expectedSecondChannelFollows)
+  })
+
+  it('should properly rebuild the aggregate', async () => {
+    const expectedFirstChannelFollows: ChannelFollowsInfo = {
+      id: FIRST_CHANNEL_ID,
+      follows: 3,
+    }
+    const expectedSecondChannelFollows: ChannelFollowsInfo = {
+      id: SECOND_CHANNEL_ID,
+      follows: 4,
+    }
+
+    const checkFollows = async () => {
+      const firstChannelFollows = await getChannelFollows(FIRST_CHANNEL_ID)
+      const secondChannelFollows = await getChannelFollows(SECOND_CHANNEL_ID)
+
+      expect(firstChannelFollows).toEqual(expectedFirstChannelFollows)
+      expect(secondChannelFollows).toEqual(expectedSecondChannelFollows)
+    }
+
+    await followChannel(FIRST_CHANNEL_ID)
+    await followChannel(FIRST_CHANNEL_ID)
+    await followChannel(FIRST_CHANNEL_ID)
+
+    await followChannel(SECOND_CHANNEL_ID)
+    await followChannel(SECOND_CHANNEL_ID)
+    await followChannel(SECOND_CHANNEL_ID)
+    await followChannel(SECOND_CHANNEL_ID)
+    await followChannel(SECOND_CHANNEL_ID)
+    await unfollowChannel(SECOND_CHANNEL_ID)
+
+    await checkFollows()
+
+    await server.stop()
+    aggregates = await buildAggregates()
+    server = await createServer(mongoose, aggregates)
+    const testClient = createTestClient(server)
+    query = testClient.query
+    mutate = testClient.mutate
+
+    await checkFollows()
+  })
+
+  it('should properly handle saving events across buckets', async () => {
+    const eventsCount = TEST_BUCKET_SIZE * 2 + 1
+
+    const expectedChannelFollows: ChannelFollowsInfo = {
+      id: FIRST_CHANNEL_ID,
+      follows: eventsCount,
+    }
+
+    for (let i = 0; i < eventsCount; i++) {
+      await followChannel(FIRST_CHANNEL_ID)
+    }
+
+    const channelFollows = await getChannelFollows(FIRST_CHANNEL_ID)
+    expect(channelFollows).toEqual(expectedChannelFollows)
+  })
+})

+ 44 - 0
tests/queries/follows.ts

@@ -0,0 +1,44 @@
+import { gql } from 'apollo-server-express'
+import { ChannelFollowsInfo } from '../../src/entities/ChannelFollowsInfo'
+
+export const GET_CHANNEL_FOLLOWS = gql`
+  query GetChannelFollows($channelId: ID!) {
+    channelFollows(channelId: $channelId) {
+      id
+      follows
+    }
+  }
+`
+export type GetChannelFollows = {
+  channelFollows: ChannelFollowsInfo | null
+}
+export type GetChannelFollowsArgs = {
+  channelId: string
+}
+
+export const FOLLOW_CHANNEL = gql`
+  mutation FollowChannel($channelId: ID!) {
+    followChannel(channelId: $channelId) {
+      id
+      follows
+    }
+  }
+`
+export const UNFOLLOW_CHANNEL = gql`
+  mutation FollowChannel($channelId: ID!) {
+    unfollowChannel(channelId: $channelId) {
+      id
+      follows
+    }
+  }
+`
+export type FollowChannel = {
+  followChannel: ChannelFollowsInfo
+}
+export type FollowChannelArgs = {
+  channelId: string
+}
+export type UnfollowChannel = {
+  unfollowChannel: ChannelFollowsInfo
+}
+export type UnfollowChannelArgs = FollowChannelArgs

+ 48 - 0
tests/queries/views.ts

@@ -0,0 +1,48 @@
+import { gql } from 'apollo-server-express'
+import { EntityViewsInfo } from '../../src/entities/EntityViewsInfo'
+
+export const GET_VIDEO_VIEWS = gql`
+  query GetVideoViews($videoId: ID!) {
+    videoViews(videoId: $videoId) {
+      id
+      views
+    }
+  }
+`
+export type GetVideoViews = {
+  videoViews: EntityViewsInfo | null
+}
+export type GetVideoViewsArgs = {
+  videoId: string
+}
+
+export const GET_CHANNEL_VIEWS = gql`
+  query GetChannelViews($channelId: ID!) {
+    channelViews(channelId: $channelId) {
+      id
+      views
+    }
+  }
+`
+export type GetChannelViews = {
+  channelViews: EntityViewsInfo | null
+}
+export type GetChannelViewsArgs = {
+  channelId: string
+}
+
+export const ADD_VIDEO_VIEW = gql`
+  mutation AddVideoView($videoId: ID!, $channelId: ID!) {
+    addVideoView(videoId: $videoId, channelId: $channelId) {
+      id
+      views
+    }
+  }
+`
+export type AddVideoView = {
+  addVideoView: EntityViewsInfo
+}
+export type AddVideoViewArgs = {
+  videoId: string
+  channelId: string
+}

+ 35 - 0
tests/server.test.ts

@@ -0,0 +1,35 @@
+import { buildAggregates, connectMongoose, createServer } from '../src/server'
+import { Mongoose } from 'mongoose'
+import { ApolloServer } from 'apollo-server-express'
+import { Aggregates } from '../src/types'
+
+describe('The server', () => {
+  let server: ApolloServer
+  let mongoose: Mongoose
+  let aggregates: Aggregates
+
+  beforeEach(async () => {
+    mongoose = await connectMongoose(process.env.MONGO_URL!)
+    aggregates = await buildAggregates()
+    server = await createServer(mongoose, aggregates)
+  })
+
+  afterEach(async () => {
+    await server.stop()
+    await mongoose.disconnect()
+  })
+
+  it('should run without any issues', async () => {
+    expect(true).toBe(true)
+  })
+
+  it('should start with empty aggregates', async () => {
+    const videoViewsMap = aggregates.viewsAggregate.getVideoViewsMap()
+    const channelViewsMap = aggregates.viewsAggregate.getChannelViewsMap()
+    const channelFollowsMap = aggregates.followsAggregate.getChannelFollowsMap()
+
+    expect(videoViewsMap).toEqual({})
+    expect(channelViewsMap).toEqual({})
+    expect(channelFollowsMap).toEqual({})
+  })
+})

+ 3 - 0
tests/setup.ts

@@ -0,0 +1,3 @@
+export const TEST_BUCKET_SIZE = 20
+
+jest.mock('../src/config', () => ({ bucketSize: TEST_BUCKET_SIZE }))

+ 235 - 0
tests/views.test.ts

@@ -0,0 +1,235 @@
+import { ApolloServer } from 'apollo-server-express'
+import { Mongoose } from 'mongoose'
+import { Aggregates } from '../src/types'
+import { ApolloServerTestClient } from 'apollo-server-testing/dist/createTestClient'
+import { buildAggregates, connectMongoose, createServer } from '../src/server'
+import { createTestClient } from 'apollo-server-testing'
+import {
+  ADD_VIDEO_VIEW,
+  AddVideoView,
+  AddVideoViewArgs,
+  GET_CHANNEL_VIEWS,
+  GET_VIDEO_VIEWS,
+  GetChannelViews,
+  GetChannelViewsArgs,
+  GetVideoViews,
+  GetVideoViewsArgs,
+} from './queries/views'
+import { EntityViewsInfo } from '../src/entities/EntityViewsInfo'
+import { VideoEventsBucketModel } from '../src/models/VideoEvent'
+import { TEST_BUCKET_SIZE } from './setup'
+
+const FIRST_VIDEO_ID = '12'
+const SECOND_VIDEO_ID = '13'
+const FIRST_CHANNEL_ID = '22'
+const SECOND_CHANNEL_ID = '23'
+
+describe('Video and channel views resolver', () => {
+  let server: ApolloServer
+  let mongoose: Mongoose
+  let aggregates: Aggregates
+  let query: ApolloServerTestClient['query']
+  let mutate: ApolloServerTestClient['mutate']
+
+  beforeEach(async () => {
+    mongoose = await connectMongoose(process.env.MONGO_URL!)
+    aggregates = await buildAggregates()
+    server = await createServer(mongoose, aggregates)
+    const testClient = createTestClient(server)
+    query = testClient.query
+    mutate = testClient.mutate
+  })
+
+  afterEach(async () => {
+    await server.stop()
+    await VideoEventsBucketModel.deleteMany({})
+    await mongoose.disconnect()
+  })
+
+  const addVideoView = async (videoId: string, channelId: string) => {
+    const addVideoViewResponse = await mutate<AddVideoView, AddVideoViewArgs>({
+      mutation: ADD_VIDEO_VIEW,
+      variables: { videoId, channelId },
+    })
+    expect(addVideoViewResponse.errors).toBeUndefined()
+    return addVideoViewResponse.data?.addVideoView
+  }
+
+  const getVideoViews = async (videoId: string) => {
+    const videoViewsResponse = await query<GetVideoViews, GetVideoViewsArgs>({
+      query: GET_VIDEO_VIEWS,
+      variables: { videoId },
+    })
+    expect(videoViewsResponse.errors).toBeUndefined()
+    return videoViewsResponse.data?.videoViews
+  }
+
+  const getChannelViews = async (channelId: string) => {
+    const channelViewsResponse = await query<GetChannelViews, GetChannelViewsArgs>({
+      query: GET_CHANNEL_VIEWS,
+      variables: { channelId },
+    })
+    expect(channelViewsResponse.errors).toBeUndefined()
+    return channelViewsResponse.data?.channelViews
+  }
+
+  it('should return null for unknown video and channel views', async () => {
+    const videoViews = await getVideoViews(FIRST_VIDEO_ID)
+    const channelViews = await getChannelViews(FIRST_CHANNEL_ID)
+
+    expect(videoViews).toBeNull()
+    expect(channelViews).toBeNull()
+  })
+
+  it('should properly save video and channel views', async () => {
+    const expectedVideoViews: EntityViewsInfo = {
+      id: FIRST_VIDEO_ID,
+      views: 1,
+    }
+    const expectedChannelViews: EntityViewsInfo = {
+      id: FIRST_CHANNEL_ID,
+      views: 1,
+    }
+    const checkViews = async () => {
+      const videoViews = await getVideoViews(FIRST_VIDEO_ID)
+      const channelViews = await getChannelViews(FIRST_CHANNEL_ID)
+
+      expect(videoViews).toEqual(expectedVideoViews)
+      expect(channelViews).toEqual(expectedChannelViews)
+    }
+
+    let addVideoViewData = await addVideoView(FIRST_VIDEO_ID, FIRST_CHANNEL_ID)
+    expect(addVideoViewData).toEqual(expectedVideoViews)
+
+    await checkViews()
+
+    expectedVideoViews.views++
+    expectedChannelViews.views++
+
+    addVideoViewData = await addVideoView(FIRST_VIDEO_ID, FIRST_CHANNEL_ID)
+    expect(addVideoViewData).toEqual(expectedVideoViews)
+
+    await checkViews()
+  })
+
+  it('should distinct views of separate videos', async () => {
+    const expectedFirstVideoViews: EntityViewsInfo = {
+      id: FIRST_VIDEO_ID,
+      views: 1,
+    }
+    const expectedSecondVideoViews: EntityViewsInfo = {
+      id: SECOND_VIDEO_ID,
+      views: 1,
+    }
+
+    const addFirstVideoViewData = await addVideoView(FIRST_VIDEO_ID, FIRST_CHANNEL_ID)
+    const addSecondVideoViewData = await addVideoView(SECOND_VIDEO_ID, FIRST_CHANNEL_ID)
+
+    expect(addFirstVideoViewData).toEqual(expectedFirstVideoViews)
+    expect(addSecondVideoViewData).toEqual(expectedSecondVideoViews)
+
+    expectedFirstVideoViews.views++
+
+    await addVideoView(FIRST_VIDEO_ID, FIRST_CHANNEL_ID)
+
+    const firstVideoViews = await getVideoViews(FIRST_VIDEO_ID)
+    const secondVideoViews = await getVideoViews(SECOND_VIDEO_ID)
+
+    expect(firstVideoViews).toEqual(expectedFirstVideoViews)
+    expect(secondVideoViews).toEqual(expectedSecondVideoViews)
+  })
+
+  it('should distinct views of separate channels', async () => {
+    const expectedFirstChanelViews: EntityViewsInfo = {
+      id: FIRST_CHANNEL_ID,
+      views: 1,
+    }
+    const expectedSecondChannelViews: EntityViewsInfo = {
+      id: SECOND_CHANNEL_ID,
+      views: 1,
+    }
+
+    await addVideoView(FIRST_VIDEO_ID, FIRST_CHANNEL_ID)
+    await addVideoView(SECOND_VIDEO_ID, SECOND_CHANNEL_ID)
+
+    const firstChannelViews = await getChannelViews(FIRST_CHANNEL_ID)
+    const secondChannelViews = await getChannelViews(SECOND_CHANNEL_ID)
+
+    expect(firstChannelViews).toEqual(expectedFirstChanelViews)
+    expect(secondChannelViews).toEqual(expectedSecondChannelViews)
+  })
+
+  it('should properly aggregate views of a channel', async () => {
+    const expectedChannelViews: EntityViewsInfo = {
+      id: FIRST_CHANNEL_ID,
+      views: 2,
+    }
+
+    await addVideoView(FIRST_VIDEO_ID, FIRST_CHANNEL_ID)
+    await addVideoView(SECOND_VIDEO_ID, FIRST_CHANNEL_ID)
+
+    const channelViews = await getChannelViews(FIRST_CHANNEL_ID)
+
+    expect(channelViews).toEqual(expectedChannelViews)
+  })
+
+  it('should properly rebuild the aggregate', async () => {
+    const expectedFirstVideoViews: EntityViewsInfo = {
+      id: FIRST_VIDEO_ID,
+      views: 3,
+    }
+    const expectedSecondVideoViews: EntityViewsInfo = {
+      id: SECOND_VIDEO_ID,
+      views: 4,
+    }
+    const expectedChannelViews: EntityViewsInfo = {
+      id: FIRST_CHANNEL_ID,
+      views: 7,
+    }
+
+    const checkViews = async () => {
+      const firstVideoViews = await getVideoViews(FIRST_VIDEO_ID)
+      const secondVideoViews = await getVideoViews(SECOND_VIDEO_ID)
+      const channelViews = await getChannelViews(FIRST_CHANNEL_ID)
+
+      expect(firstVideoViews).toEqual(expectedFirstVideoViews)
+      expect(secondVideoViews).toEqual(expectedSecondVideoViews)
+      expect(channelViews).toEqual(expectedChannelViews)
+    }
+
+    await addVideoView(FIRST_VIDEO_ID, FIRST_CHANNEL_ID)
+    await addVideoView(FIRST_VIDEO_ID, FIRST_CHANNEL_ID)
+    await addVideoView(FIRST_VIDEO_ID, FIRST_CHANNEL_ID)
+
+    await addVideoView(SECOND_VIDEO_ID, FIRST_CHANNEL_ID)
+    await addVideoView(SECOND_VIDEO_ID, FIRST_CHANNEL_ID)
+    await addVideoView(SECOND_VIDEO_ID, FIRST_CHANNEL_ID)
+    await addVideoView(SECOND_VIDEO_ID, FIRST_CHANNEL_ID)
+
+    await checkViews()
+
+    await server.stop()
+    aggregates = await buildAggregates()
+    server = await createServer(mongoose, aggregates)
+    const testClient = createTestClient(server)
+    query = testClient.query
+    mutate = testClient.mutate
+
+    await checkViews()
+  })
+
+  it('should properly handle saving events across buckets', async () => {
+    const eventsCount = TEST_BUCKET_SIZE * 2 + 1
+    const expectedVideoViews: EntityViewsInfo = {
+      id: FIRST_VIDEO_ID,
+      views: eventsCount,
+    }
+
+    for (let i = 0; i < eventsCount; i++) {
+      await addVideoView(FIRST_VIDEO_ID, FIRST_CHANNEL_ID)
+    }
+
+    const videoViews = await getVideoViews(FIRST_VIDEO_ID)
+    expect(videoViews).toEqual(expectedVideoViews)
+  })
+})

+ 7 - 0
tsconfig.build.json

@@ -0,0 +1,7 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "outDir": "dist"
+  },
+  "files": ["src/main.ts"]
+}

+ 0 - 1
tsconfig.json

@@ -9,7 +9,6 @@
     "resolveJsonModule": true,
     "isolatedModules": true,
     "outDir": "dist",
-    "rootDir": "src",
     "sourceMap": true,
     "strict": true,
     "emitDecoratorMetadata": true,

ファイルの差分が大きいため隠しています
+ 752 - 29
yarn.lock


この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません